diff --git a/src/main/java/com/bobrust/generator/Model.java b/src/main/java/com/bobrust/generator/Model.java
index 68e7d07..8263f93 100644
--- a/src/main/java/com/bobrust/generator/Model.java
+++ b/src/main/java/com/bobrust/generator/Model.java
@@ -78,6 +78,19 @@ private void addShape(Circle shape) {
}
}
+ /**
+ * Add a pre-defined shape to this model without running optimization.
+ * Used by MultiResModel to propagate shapes from lower to higher resolutions.
+ */
+ public void addExternalShape(Circle shape) {
+ addShape(shape);
+ }
+
+ /** Returns the current model score. */
+ public float getScore() {
+ return score;
+ }
+
private static final int max_random_states = 1000;
private static final int age = 100;
private static final int times = Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
diff --git a/src/main/java/com/bobrust/generator/MultiResModel.java b/src/main/java/com/bobrust/generator/MultiResModel.java
new file mode 100644
index 0000000..5bd1a4a
--- /dev/null
+++ b/src/main/java/com/bobrust/generator/MultiResModel.java
@@ -0,0 +1,161 @@
+package com.bobrust.generator;
+
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+
+/**
+ * Progressive multi-resolution model for shape generation.
+ *
+ * Uses a resolution pyramid:
+ *
+ * - Level 2: quarter resolution (first 10% of shapes)
+ * - Level 1: half resolution (next 30% of shapes)
+ * - Level 0: full resolution (remaining 60% of shapes)
+ *
+ *
+ * Shapes generated at lower resolutions are scaled and propagated to all
+ * finer resolution levels, so the full-resolution model stays in sync.
+ */
+public class MultiResModel {
+ /** Models at each resolution level: [0]=full, [1]=half, [2]=quarter */
+ private final Model[] levels;
+
+ /** Dimensions at each level */
+ private final int[][] dims;
+
+ /** The full-resolution target image */
+ private final BorstImage fullTarget;
+
+ private final int backgroundRGB;
+ private final int alpha;
+ private int shapesAdded;
+
+ /**
+ * Create a multi-resolution model.
+ *
+ * @param target the full-resolution target image
+ * @param backgroundRGB background color
+ * @param alpha alpha value for blending
+ */
+ public MultiResModel(BorstImage target, int backgroundRGB, int alpha) {
+ this.fullTarget = target;
+ this.backgroundRGB = backgroundRGB;
+ this.alpha = alpha;
+ this.shapesAdded = 0;
+
+ int fw = target.width;
+ int fh = target.height;
+
+ dims = new int[][] {
+ { fw, fh }, // Level 0: full
+ { Math.max(1, fw / 2), Math.max(1, fh / 2) }, // Level 1: half
+ { Math.max(1, fw / 4), Math.max(1, fh / 4) }, // Level 2: quarter
+ };
+
+ levels = new Model[3];
+ levels[0] = new Model(target, backgroundRGB, alpha);
+ levels[1] = new Model(scaleImage(target, dims[1][0], dims[1][1]), backgroundRGB, alpha);
+ levels[2] = new Model(scaleImage(target, dims[2][0], dims[2][1]), backgroundRGB, alpha);
+ }
+
+ /**
+ * Process one shape at the appropriate resolution level.
+ *
+ * @param currentShape current shape index (0-based)
+ * @param maxShapes total number of shapes to generate
+ * @return the counter from the worker (number of energy evaluations)
+ */
+ public int processStep(int currentShape, int maxShapes) {
+ float progress = (float) currentShape / maxShapes;
+ int level;
+ if (progress < 0.10f) {
+ level = 2; // Quarter resolution
+ } else if (progress < 0.40f) {
+ level = 1; // Half resolution
+ } else {
+ level = 0; // Full resolution
+ }
+
+ // Run generation at selected level
+ int n = levels[level].processStep();
+
+ // Get the shape that was just added
+ Circle shape = levels[level].shapes.get(levels[level].shapes.size() - 1);
+
+ // Propagate the shape to all finer levels
+ for (int i = level - 1; i >= 0; i--) {
+ Circle scaled = scaleCircle(shape, level, i);
+ levels[i].addExternalShape(scaled);
+ }
+
+ shapesAdded++;
+ return n;
+ }
+
+ /**
+ * Scale a circle from one resolution level to another.
+ */
+ private Circle scaleCircle(Circle shape, int fromLevel, int toLevel) {
+ float scaleX = (float) dims[toLevel][0] / dims[fromLevel][0];
+ float scaleY = (float) dims[toLevel][1] / dims[fromLevel][1];
+
+ int newX = Math.round(shape.x * scaleX);
+ int newY = Math.round(shape.y * scaleY);
+
+ // Scale the radius and snap to nearest valid size
+ int scaledR = Math.round(shape.r * scaleX);
+ int newR = BorstUtils.getClosestSize(scaledR);
+
+ // Create a new circle in the target level's worker
+ // We need to access the worker through the model
+ return new Circle(getWorker(levels[toLevel]), newX, newY, newR);
+ }
+
+ /**
+ * Get the full-resolution model (level 0).
+ */
+ public Model getFullResModel() {
+ return levels[0];
+ }
+
+ /**
+ * Get the model at a specific level.
+ */
+ public Model getModel(int level) {
+ return levels[level];
+ }
+
+ /**
+ * Get the number of shapes added so far.
+ */
+ public int getShapesAdded() {
+ return shapesAdded;
+ }
+
+ /**
+ * Scale a BorstImage to a new size.
+ */
+ private static BorstImage scaleImage(BorstImage source, int newWidth, int newHeight) {
+ BufferedImage scaled = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB);
+ Graphics2D g = scaled.createGraphics();
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+ g.drawImage(source.image, 0, 0, newWidth, newHeight, null);
+ g.dispose();
+ return new BorstImage(scaled);
+ }
+
+ /**
+ * Reflectively get the Worker from a Model. This is needed because Worker
+ * is package-private and we need it to create Circle instances.
+ */
+ private static Worker getWorker(Model model) {
+ try {
+ var field = Model.class.getDeclaredField("worker");
+ field.setAccessible(true);
+ return (Worker) field.get(model);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to access Model.worker", e);
+ }
+ }
+}
diff --git a/src/main/java/com/bobrust/util/data/AppConstants.java b/src/main/java/com/bobrust/util/data/AppConstants.java
index ec40b60..429bb46 100644
--- a/src/main/java/com/bobrust/util/data/AppConstants.java
+++ b/src/main/java/com/bobrust/util/data/AppConstants.java
@@ -46,6 +46,10 @@ public interface AppConstants {
// TSP cost function weights
float TSP_W_PALETTE = 3.0f; // Weight for palette change cost
float TSP_W_DISTANCE = 1.0f; // Weight for Euclidean distance cost
+
+ // When true, use progressive multi-resolution generation:
+ // first 10% shapes at quarter res, next 30% at half res, remaining 60% at full res
+ boolean USE_PROGRESSIVE_RESOLUTION = true;
// Average canvas colors. Used as default colors
Color CANVAS_AVERAGE = new Color(0xb3aba0);
diff --git a/src/test/java/com/bobrust/generator/ProgressiveResolutionTest.java b/src/test/java/com/bobrust/generator/ProgressiveResolutionTest.java
new file mode 100644
index 0000000..4fd1065
--- /dev/null
+++ b/src/test/java/com/bobrust/generator/ProgressiveResolutionTest.java
@@ -0,0 +1,270 @@
+package com.bobrust.generator;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import javax.imageio.ImageIO;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for Proposal 6: Progressive Multi-Resolution Generation.
+ *
+ * Verifies that multi-resolution generation produces reasonable quality,
+ * benchmarks timing vs single-resolution, and generates before/after
+ * comparison images in test-results/proposal6/.
+ */
+class ProgressiveResolutionTest {
+ private static final int ALPHA = 128;
+ private static final int BACKGROUND = 0xFFFFFFFF;
+ private static final int MAX_SHAPES = 100;
+ private static final File OUTPUT_DIR = new File("test-results/proposal6");
+
+ @BeforeAll
+ static void setup() {
+ OUTPUT_DIR.mkdirs();
+ }
+
+ // ---- Test 1: Multi-res model produces valid output ----
+
+ @Test
+ void testMultiResModelProducesValidOutput() {
+ BufferedImage img = TestImageGenerator.createPhotoDetail();
+ BorstImage target = new BorstImage(ensureArgb(img));
+
+ MultiResModel multiRes = new MultiResModel(target, BACKGROUND, ALPHA);
+
+ for (int i = 0; i < MAX_SHAPES; i++) {
+ multiRes.processStep(i, MAX_SHAPES);
+ }
+
+ Model fullModel = multiRes.getFullResModel();
+
+ // Should have produced the expected number of shapes
+ assertEquals(MAX_SHAPES, fullModel.shapes.size(),
+ "Full-res model should have " + MAX_SHAPES + " shapes");
+
+ // Score should have improved from initial
+ float initialScore = BorstCore.differenceFull(target,
+ createBackground(target.width, target.height));
+ assertTrue(fullModel.getScore() < initialScore,
+ "Score should improve: initial=" + initialScore + " final=" + fullModel.getScore());
+
+ System.out.println("Multi-res final score: " + fullModel.getScore());
+ }
+
+ // ---- Test 2: Single-res vs multi-res quality comparison ----
+
+ @Test
+ void testQualityComparisonAndImages() throws IOException {
+ String[] imageNames = {"photo_detail", "nature"};
+ BufferedImage[] images = {
+ TestImageGenerator.createPhotoDetail(),
+ TestImageGenerator.createNature()
+ };
+
+ for (int idx = 0; idx < images.length; idx++) {
+ BufferedImage img = ensureArgb(images[idx]);
+ BorstImage target = new BorstImage(img);
+ String name = imageNames[idx];
+
+ // --- Single resolution ---
+ Model singleRes = new Model(target, BACKGROUND, ALPHA);
+ long singleStart = System.nanoTime();
+ for (int i = 0; i < MAX_SHAPES; i++) {
+ singleRes.processStep();
+ }
+ long singleTime = System.nanoTime() - singleStart;
+ float singleScore = singleRes.getScore();
+
+ // --- Multi resolution ---
+ MultiResModel multiRes = new MultiResModel(target, BACKGROUND, ALPHA);
+ long multiStart = System.nanoTime();
+ for (int i = 0; i < MAX_SHAPES; i++) {
+ multiRes.processStep(i, MAX_SHAPES);
+ }
+ long multiTime = System.nanoTime() - multiStart;
+ float multiScore = multiRes.getFullResModel().getScore();
+
+ double singleMs = singleTime / 1_000_000.0;
+ double multiMs = multiTime / 1_000_000.0;
+ double speedup = (double) singleTime / multiTime;
+
+ System.out.println(name + ":");
+ System.out.println(" Single-res: score=" + singleScore +
+ " time=" + String.format("%.0f", singleMs) + "ms");
+ System.out.println(" Multi-res: score=" + multiScore +
+ " time=" + String.format("%.0f", multiMs) + "ms");
+ System.out.println(" Speedup: " + String.format("%.2fx", speedup));
+
+ // Save comparison images
+ ImageIO.write(img, "png", new File(OUTPUT_DIR, name + "_target.png"));
+ ImageIO.write(singleRes.current.image, "png",
+ new File(OUTPUT_DIR, name + "_single_res.png"));
+ ImageIO.write(multiRes.getFullResModel().current.image, "png",
+ new File(OUTPUT_DIR, name + "_multi_res.png"));
+
+ // Generate difference image (amplified 4x for visibility)
+ BufferedImage diff = generateDiffImage(
+ singleRes.current.image,
+ multiRes.getFullResModel().current.image);
+ ImageIO.write(diff, "png", new File(OUTPUT_DIR, name + "_diff.png"));
+
+ // Multi-res quality should be within reasonable range of single-res
+ // (allow up to 30% worse score since early shapes are optimized at lower resolution)
+ assertTrue(multiScore <= singleScore * 1.30f,
+ name + ": Multi-res score (" + multiScore +
+ ") should not be dramatically worse than single-res (" + singleScore + ")");
+ }
+ }
+
+ // ---- Test 3: Resolution level selection is correct ----
+
+ @Test
+ void testResolutionLevelSelection() {
+ BufferedImage img = TestImageGenerator.createSolid();
+ BorstImage target = new BorstImage(ensureArgb(img));
+
+ MultiResModel multiRes = new MultiResModel(target, BACKGROUND, ALPHA);
+
+ // Track shapes added at each level by checking model shape counts
+ int level0Before = multiRes.getModel(0).shapes.size();
+ int level1Before = multiRes.getModel(1).shapes.size();
+ int level2Before = multiRes.getModel(2).shapes.size();
+
+ int totalShapes = 100;
+ // First 10 shapes should use level 2 (quarter)
+ for (int i = 0; i < 10; i++) {
+ multiRes.processStep(i, totalShapes);
+ }
+
+ // Level 2 should have generated 10 shapes
+ assertEquals(10, multiRes.getModel(2).shapes.size() - level2Before,
+ "Level 2 should have 10 shapes after first 10% of generation");
+
+ // Next 30 shapes should use level 1 (half)
+ for (int i = 10; i < 40; i++) {
+ multiRes.processStep(i, totalShapes);
+ }
+
+ // Level 1 should have generated 30 new shapes (plus 10 propagated from level 2)
+ int level1Shapes = multiRes.getModel(1).shapes.size() - level1Before;
+ assertEquals(40, level1Shapes,
+ "Level 1 should have 40 shapes (30 generated + 10 propagated)");
+
+ // Remaining 60 shapes at full resolution
+ for (int i = 40; i < totalShapes; i++) {
+ multiRes.processStep(i, totalShapes);
+ }
+
+ // Full-res model should have all shapes
+ assertEquals(totalShapes, multiRes.getModel(0).shapes.size() - level0Before,
+ "Level 0 should have all " + totalShapes + " shapes");
+ }
+
+ // ---- Test 4: Circle scaling between levels ----
+
+ @Test
+ void testShapePropagation() {
+ BufferedImage img = TestImageGenerator.createGradient();
+ BorstImage target = new BorstImage(ensureArgb(img));
+
+ MultiResModel multiRes = new MultiResModel(target, BACKGROUND, ALPHA);
+
+ // Generate a single shape at quarter resolution
+ multiRes.processStep(0, 100);
+
+ // Shape should have been propagated to half-res and full-res
+ assertTrue(multiRes.getModel(2).shapes.size() >= 1,
+ "Quarter-res model should have at least 1 shape");
+ assertTrue(multiRes.getModel(1).shapes.size() >= 1,
+ "Half-res model should have at least 1 propagated shape");
+ assertTrue(multiRes.getModel(0).shapes.size() >= 1,
+ "Full-res model should have at least 1 propagated shape");
+ }
+
+ // ---- Test 5: Timing benchmark ----
+
+ @Test
+ void testTimingBenchmark() {
+ BufferedImage img = TestImageGenerator.createPhotoDetail();
+ BorstImage target = new BorstImage(ensureArgb(img));
+ int shapes = 50;
+
+ // Warm up
+ Model warmup = new Model(target, BACKGROUND, ALPHA);
+ for (int i = 0; i < 10; i++) warmup.processStep();
+
+ // Single resolution
+ Model singleRes = new Model(target, BACKGROUND, ALPHA);
+ long singleStart = System.nanoTime();
+ for (int i = 0; i < shapes; i++) singleRes.processStep();
+ long singleNs = System.nanoTime() - singleStart;
+
+ // Multi resolution
+ MultiResModel multiRes = new MultiResModel(target, BACKGROUND, ALPHA);
+ long multiStart = System.nanoTime();
+ for (int i = 0; i < shapes; i++) multiRes.processStep(i, shapes);
+ long multiNs = System.nanoTime() - multiStart;
+
+ System.out.println("Timing (" + shapes + " shapes):");
+ System.out.println(" Single: " + String.format("%.0f", singleNs / 1e6) + " ms");
+ System.out.println(" Multi: " + String.format("%.0f", multiNs / 1e6) + " ms");
+ System.out.println(" Ratio: " + String.format("%.2fx", (double) singleNs / multiNs));
+
+ // Multi-res should not be catastrophically slower (overhead of managing 3 models)
+ assertTrue(multiNs < singleNs * 3,
+ "Multi-res should not be more than 3x slower than single-res");
+ }
+
+ // ---- Helpers ----
+
+ private static BufferedImage ensureArgb(BufferedImage img) {
+ if (img.getType() == BufferedImage.TYPE_INT_ARGB) return img;
+ BufferedImage argb = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_ARGB);
+ Graphics2D g = argb.createGraphics();
+ g.drawImage(img, 0, 0, null);
+ g.dispose();
+ return argb;
+ }
+
+ private static BorstImage createBackground(int w, int h) {
+ BorstImage bg = new BorstImage(w, h);
+ Arrays.fill(bg.pixels, BACKGROUND);
+ return bg;
+ }
+
+ /**
+ * Generate a difference image (amplified 4x) between two images.
+ */
+ private static BufferedImage generateDiffImage(BufferedImage a, BufferedImage b) {
+ int w = a.getWidth();
+ int h = a.getHeight();
+ BufferedImage diff = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
+
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ int ca = a.getRGB(x, y);
+ int cb = b.getRGB(x, y);
+
+ int dr = Math.abs(((ca >> 16) & 0xff) - ((cb >> 16) & 0xff));
+ int dg = Math.abs(((ca >> 8) & 0xff) - ((cb >> 8) & 0xff));
+ int db = Math.abs((ca & 0xff) - (cb & 0xff));
+
+ // Amplify 4x
+ dr = Math.min(255, dr * 4);
+ dg = Math.min(255, dg * 4);
+ db = Math.min(255, db * 4);
+
+ diff.setRGB(x, y, 0xFF000000 | (dr << 16) | (dg << 8) | db);
+ }
+ }
+
+ return diff;
+ }
+}
diff --git a/test-results/proposal6/nature_diff.png b/test-results/proposal6/nature_diff.png
new file mode 100644
index 0000000..9cdd8db
Binary files /dev/null and b/test-results/proposal6/nature_diff.png differ
diff --git a/test-results/proposal6/nature_multi_res.png b/test-results/proposal6/nature_multi_res.png
new file mode 100644
index 0000000..a3395f0
Binary files /dev/null and b/test-results/proposal6/nature_multi_res.png differ
diff --git a/test-results/proposal6/nature_single_res.png b/test-results/proposal6/nature_single_res.png
new file mode 100644
index 0000000..f839403
Binary files /dev/null and b/test-results/proposal6/nature_single_res.png differ
diff --git a/test-results/proposal6/nature_target.png b/test-results/proposal6/nature_target.png
new file mode 100644
index 0000000..674423b
Binary files /dev/null and b/test-results/proposal6/nature_target.png differ
diff --git a/test-results/proposal6/photo_detail_diff.png b/test-results/proposal6/photo_detail_diff.png
new file mode 100644
index 0000000..cbff871
Binary files /dev/null and b/test-results/proposal6/photo_detail_diff.png differ
diff --git a/test-results/proposal6/photo_detail_multi_res.png b/test-results/proposal6/photo_detail_multi_res.png
new file mode 100644
index 0000000..70eb501
Binary files /dev/null and b/test-results/proposal6/photo_detail_multi_res.png differ
diff --git a/test-results/proposal6/photo_detail_single_res.png b/test-results/proposal6/photo_detail_single_res.png
new file mode 100644
index 0000000..d1ab3be
Binary files /dev/null and b/test-results/proposal6/photo_detail_single_res.png differ
diff --git a/test-results/proposal6/photo_detail_target.png b/test-results/proposal6/photo_detail_target.png
new file mode 100644
index 0000000..9065aaf
Binary files /dev/null and b/test-results/proposal6/photo_detail_target.png differ