Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/main/java/com/bobrust/generator/Circle.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,25 @@ public void mutateShape() {
}

public void randomize() {
randomize(null);
}

/**
* Randomize circle position and size.
* When an {@link ErrorMap} is provided and error-guided placement is enabled,
* 80% of placements are biased toward high-error regions via importance
* sampling. The remaining 20% use uniform random placement for exploration.
*/
public void randomize(ErrorMap errorMap) {
Random rnd = worker.getRandom();
this.x = rnd.nextInt(worker.w);
this.y = rnd.nextInt(worker.h);
if (errorMap != null && rnd.nextFloat() < 0.8f) {
int[] pos = errorMap.samplePosition(rnd);
this.x = pos[0];
this.y = pos[1];
} else {
this.x = rnd.nextInt(worker.w);
this.y = rnd.nextInt(worker.h);
}
this.r = BorstUtils.SIZES[rnd.nextInt(BorstUtils.SIZES.length)];
}

Expand Down
233 changes: 233 additions & 0 deletions src/main/java/com/bobrust/generator/ErrorMap.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package com.bobrust.generator;

import java.util.Random;

/**
* Spatial error map that tracks per-cell error across the image and supports
* importance sampling to bias circle placement toward high-error regions.
*
* The image is divided into a coarse grid (e.g. 32x32). Each cell stores the
* sum of squared per-pixel error for its region. An alias table enables O(1)
* weighted random sampling from the grid.
*/
public class ErrorMap {
private static final int DEFAULT_GRID_DIM = 32;

final int gridWidth;
final int gridHeight;
final int cellWidth;
final int cellHeight;
final int imageWidth;
final int imageHeight;
final float[] cellErrors;

// Alias table fields for O(1) weighted sampling
private int[] alias;
private float[] prob;
private boolean tableValid;

public ErrorMap(int imageWidth, int imageHeight) {
this(imageWidth, imageHeight, DEFAULT_GRID_DIM, DEFAULT_GRID_DIM);
}

public ErrorMap(int imageWidth, int imageHeight, int gridWidth, int gridHeight) {
this.imageWidth = imageWidth;
this.imageHeight = imageHeight;
this.gridWidth = gridWidth;
this.gridHeight = gridHeight;
this.cellWidth = Math.max(1, (imageWidth + gridWidth - 1) / gridWidth);
this.cellHeight = Math.max(1, (imageHeight + gridHeight - 1) / gridHeight);
this.cellErrors = new float[gridWidth * gridHeight];
this.alias = new int[gridWidth * gridHeight];
this.prob = new float[gridWidth * gridHeight];
this.tableValid = false;
}

/**
* Compute the full error map from scratch given target and current images.
*/
public void computeFull(BorstImage target, BorstImage current) {
int w = target.width;
int h = target.height;
int n = gridWidth * gridHeight;
for (int i = 0; i < n; i++) {
cellErrors[i] = 0;
}

for (int py = 0; py < h; py++) {
int gy = py / cellHeight;
if (gy >= gridHeight) gy = gridHeight - 1;
int rowOffset = py * w;
for (int px = 0; px < w; px++) {
int gx = px / cellWidth;
if (gx >= gridWidth) gx = gridWidth - 1;

int tt = target.pixels[rowOffset + px];
int cc = current.pixels[rowOffset + px];

int dr = ((tt >>> 16) & 0xff) - ((cc >>> 16) & 0xff);
int dg = ((tt >>> 8) & 0xff) - ((cc >>> 8) & 0xff);
int db = (tt & 0xff) - (cc & 0xff);

cellErrors[gy * gridWidth + gx] += dr * dr + dg * dg + db * db;
Comment on lines +68 to +72
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ErrorMap’s error metric sums only RGB squared error, but BorstCore.differenceFull/differencePartial include alpha-channel error (da*da). If targets/backgrounds can have non-opaque alpha, the sampling weights won’t match the generator’s actual score function; consider including alpha in the per-pixel error (or explicitly documenting/enforcing that alpha is always 255).

Suggested change
int dr = ((tt >>> 16) & 0xff) - ((cc >>> 16) & 0xff);
int dg = ((tt >>> 8) & 0xff) - ((cc >>> 8) & 0xff);
int db = (tt & 0xff) - (cc & 0xff);
cellErrors[gy * gridWidth + gx] += dr * dr + dg * dg + db * db;
int da = ((tt >>> 24) & 0xff) - ((cc >>> 24) & 0xff);
int dr = ((tt >>> 16) & 0xff) - ((cc >>> 16) & 0xff);
int dg = ((tt >>> 8) & 0xff) - ((cc >>> 8) & 0xff);
int db = (tt & 0xff) - (cc & 0xff);
cellErrors[gy * gridWidth + gx] += da * da + dr * dr + dg * dg + db * db;

Copilot uses AI. Check for mistakes.
}
}

tableValid = false;
}

/**
* Incrementally update the error map after a circle was drawn.
* Only recomputes cells that overlap the circle's bounding box.
*/
public void updateIncremental(BorstImage target, BorstImage current, int cx, int cy, int cacheIndex) {
Scanline[] lines = CircleCache.CIRCLE_CACHE[cacheIndex];
int w = target.width;
int h = target.height;

// Find bounding box of affected grid cells
int minGx = Integer.MAX_VALUE, maxGx = Integer.MIN_VALUE;
int minGy = Integer.MAX_VALUE, maxGy = Integer.MIN_VALUE;
for (Scanline line : lines) {
int py = line.y + cy;
if (py < 0 || py >= h) continue;
int xs = Math.max(line.x1 + cx, 0);
int xe = Math.min(line.x2 + cx, w - 1);
if (xs > xe) continue;

int gy = Math.min(py / cellHeight, gridHeight - 1);
int gx0 = Math.min(xs / cellWidth, gridWidth - 1);
int gx1 = Math.min(xe / cellWidth, gridWidth - 1);

minGy = Math.min(minGy, gy);
maxGy = Math.max(maxGy, gy);
minGx = Math.min(minGx, gx0);
maxGx = Math.max(maxGx, gx1);
}

if (minGx > maxGx || minGy > maxGy) return;

// Recompute only the affected cells
for (int gy = minGy; gy <= maxGy; gy++) {
int pyStart = gy * cellHeight;
int pyEnd = Math.min(pyStart + cellHeight, h);
for (int gx = minGx; gx <= maxGx; gx++) {
int pxStart = gx * cellWidth;
int pxEnd = Math.min(pxStart + cellWidth, w);

float error = 0;
for (int py = pyStart; py < pyEnd; py++) {
int rowOffset = py * w;
for (int px = pxStart; px < pxEnd; px++) {
int tt = target.pixels[rowOffset + px];
int cc = current.pixels[rowOffset + px];

int dr = ((tt >>> 16) & 0xff) - ((cc >>> 16) & 0xff);
int dg = ((tt >>> 8) & 0xff) - ((cc >>> 8) & 0xff);
int db = (tt & 0xff) - (cc & 0xff);

error += dr * dr + dg * dg + db * db;
}
}
cellErrors[gy * gridWidth + gx] = error;
}
}

tableValid = false;
}

/**
* Build the alias table for O(1) weighted sampling.
* Uses Vose's alias method.
*/
private void buildAliasTable() {
int n = cellErrors.length;
float totalError = 0;
for (int i = 0; i < n; i++) {
totalError += cellErrors[i];
}

if (totalError <= 0) {
// Uniform distribution fallback
for (int i = 0; i < n; i++) {
prob[i] = 1.0f;
alias[i] = i;
}
tableValid = true;
return;
}

float[] scaled = new float[n];
for (int i = 0; i < n; i++) {
scaled[i] = cellErrors[i] * n / totalError;
}

// Partition into small and large
int[] small = new int[n];
int[] large = new int[n];
int smallCount = 0, largeCount = 0;

for (int i = 0; i < n; i++) {
if (scaled[i] < 1.0f) {
small[smallCount++] = i;
} else {
large[largeCount++] = i;
}
}

while (smallCount > 0 && largeCount > 0) {
int s = small[--smallCount];
int l = large[--largeCount];

prob[s] = scaled[s];
alias[s] = l;

scaled[l] = (scaled[l] + scaled[s]) - 1.0f;
if (scaled[l] < 1.0f) {
small[smallCount++] = l;
} else {
large[largeCount++] = l;
}
}

while (largeCount > 0) {
prob[large[--largeCount]] = 1.0f;
}
while (smallCount > 0) {
prob[small[--smallCount]] = 1.0f;
}

tableValid = true;
}

/**
* Sample a pixel position biased toward high-error regions.
* Uses the alias table for O(1) cell selection, then uniform
* random within the selected cell.
*/
public int[] samplePosition(Random rnd) {
if (!tableValid) {
buildAliasTable();
}

int n = cellErrors.length;
int cell;
int idx = rnd.nextInt(n);
if (rnd.nextFloat() < prob[idx]) {
cell = idx;
} else {
cell = alias[idx];
}

int gx = cell % gridWidth;
int gy = cell / gridWidth;

int pxStart = gx * cellWidth;
int pyStart = gy * cellHeight;

int px = pxStart + rnd.nextInt(Math.min(cellWidth, imageWidth - pxStart));
int py = pyStart + rnd.nextInt(Math.min(cellHeight, imageHeight - pyStart));

return new int[]{px, py};
Comment on lines +208 to +231
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

samplePosition() uses imageWidth/imageHeight captured at construction time, while computeFull()/updateIncremental() iterate using target.width/height. If an ErrorMap is ever computed from an image with different dimensions than the constructor args, sampling can return out-of-bounds coordinates relative to the images/worker. Consider validating dimensions in computeFull/updateIncremental (or deriving sampling bounds from the passed BorstImage dimensions).

Copilot uses AI. Check for mistakes.
}
}
10 changes: 7 additions & 3 deletions src/main/java/com/bobrust/generator/HillClimbGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
import java.util.concurrent.ThreadLocalRandom;

class HillClimbGenerator {
private static State getBestRandomState(List<State> random_states) {
private static State getBestRandomState(List<State> random_states, ErrorMap errorMap) {
final int len = random_states.size();
for (int i = 0; i < len; i++) {
State state = random_states.get(i);
state.score = -1;
state.shape.randomize();
state.shape.randomize(errorMap);
}
random_states.parallelStream().forEach(State::getEnergy);

Expand Down Expand Up @@ -162,11 +162,15 @@ static float computeCoolingRate(float initialTemp, int maxAge) {
}

public static State getBestHillClimbState(List<State> random_states, int age, int times) {
return getBestHillClimbState(random_states, age, times, null);
}

public static State getBestHillClimbState(List<State> random_states, int age, int times, ErrorMap errorMap) {
float bestEnergy = 0;
State bestState = null;

for (int i = 0; i < times; i++) {
State oldState = getBestRandomState(random_states);
State oldState = getBestRandomState(random_states, errorMap);
State state = getHillClimb(oldState, age);
float energy = state.getEnergy();

Expand Down
24 changes: 20 additions & 4 deletions src/main/java/com/bobrust/generator/Model.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.bobrust.generator;

import com.bobrust.util.data.AppConstants;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
Expand All @@ -21,6 +23,8 @@ public class Model {
public final int height;
protected float score;

private ErrorMap errorMap;

public Model(BorstImage target, int backgroundRGB, int alpha) {
int w = target.width;
int h = target.height;
Expand All @@ -33,25 +37,37 @@ public Model(BorstImage target, int backgroundRGB, int alpha) {
this.current = new BorstImage(w, h);
Arrays.fill(this.current.pixels, backgroundRGB);
this.beforeImage = new BorstImage(w, h);

this.score = BorstCore.differenceFull(target, current);
this.context = new BorstImage(w, h);
this.worker = new Worker(target, alpha);
this.alpha = alpha;

// Initialize error map if error-guided placement is enabled
if (AppConstants.USE_ERROR_GUIDED_PLACEMENT) {
this.errorMap = new ErrorMap(w, h);
this.errorMap.computeFull(target, current);
this.worker.setErrorMap(this.errorMap);
}
}

private void addShape(Circle shape) {
beforeImage.draw(current);

int cache_index = BorstUtils.getClosestSizeIndex(shape.r);
BorstColor color = BorstCore.computeColor(target, current, alpha, cache_index, shape.x, shape.y);

BorstCore.drawLines(current, color, alpha, cache_index, shape.x, shape.y);
this.score = BorstCore.differencePartial(target, beforeImage, current, score, cache_index, shape.x, shape.y);
shapes.add(shape);
colors.add(color);

BorstCore.drawLines(context, color, alpha, cache_index, shape.x, shape.y);

// Incrementally update the error map after drawing the new shape
if (errorMap != null) {
errorMap.updateIncremental(target, current, shape.x, shape.y, cache_index);
}
}

private static final int max_random_states = 1000;
Expand All @@ -69,7 +85,7 @@ public int processStep() {
}
}

State state = HillClimbGenerator.getBestHillClimbState(randomStates, age, times);
State state = HillClimbGenerator.getBestHillClimbState(randomStates, age, times, errorMap);
addShape(state.shape);

return worker.getCounter();
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/bobrust/generator/Worker.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Worker {
public final int h;
public float score;
private final AtomicInteger counter = new AtomicInteger();
private ErrorMap errorMap;

public Worker(BorstImage target, int alpha) {
this.w = target.width;
Expand All @@ -21,6 +22,16 @@ public Worker(BorstImage target, int alpha) {
this.alpha = alpha;
}

/** Returns the error map, or null if error-guided placement is disabled. */
public ErrorMap getErrorMap() {
return errorMap;
}

/** Sets the error map (called from Model when error-guided placement is enabled). */
public void setErrorMap(ErrorMap errorMap) {
this.errorMap = errorMap;
}
Comment on lines 16 to +33
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worker now stores an ErrorMap, but there are no production uses of getErrorMap()/setErrorMap() (only Model sets it). Since HillClimbGenerator takes ErrorMap explicitly, this field + accessors appear dead and can confuse the data flow; consider removing them or refactoring so the placement logic consistently sources the map from Worker.

Copilot uses AI. Check for mistakes.

/**
* Returns a thread-local Random instance for use in parallel operations.
* This avoids lock contention on a shared Random instance.
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/bobrust/util/data/AppConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public interface AppConstants {

// When true, use simulated annealing instead of pure hill climbing for shape optimization
boolean USE_SIMULATED_ANNEALING = true;

// When true, bias random circle placement toward high-error regions using importance sampling
boolean USE_ERROR_GUIDED_PLACEMENT = true;
Comment on lines +31 to +32
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

USE_ERROR_GUIDED_PLACEMENT defaults to true, which changes generator behavior and adds per-shape error-map maintenance overhead whenever Model is instantiated. If this is intended to be optional/experimental, consider defaulting to false or making it configurable at runtime (rather than a compile-time constant) so callers can opt in without rebuilding.

Suggested change
// When true, bias random circle placement toward high-error regions using importance sampling
boolean USE_ERROR_GUIDED_PLACEMENT = true;
// When true, bias random circle placement toward high-error regions using importance sampling.
// Runtime-configurable via -Dbobrust.useErrorGuidedPlacement=true and defaults to false.
boolean USE_ERROR_GUIDED_PLACEMENT = Boolean.parseBoolean(
System.getProperty("bobrust.useErrorGuidedPlacement", "false"));

Copilot uses AI. Check for mistakes.

// Average canvas colors. Used as default colors
Color CANVAS_AVERAGE = new Color(0xb3aba0);
Expand Down
Loading