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: + * + * + * 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