From e7ff7acabef438b0f5c7d803b470784b0bb265dd Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Sat, 4 Apr 2026 21:06:18 +0000 Subject: [PATCH] Add simulated annealing optimization for shape placement Replace the pure hill-climbing refinement in HillClimbGenerator with simulated annealing (SA) that can escape local minima by probabilistically accepting worse moves early in the search. The old hill climbing is kept as a fallback behind the USE_SIMULATED_ANNEALING flag in AppConstants. Key changes: - HillClimbGenerator: add getHillClimbSA(), estimateTemperature(), and computeCoolingRate() methods; dispatch via feature flag in getHillClimb() - Model: increase parallel SA chains (times) to use availableProcessors/2 - AppConstants: add USE_SIMULATED_ANNEALING boolean flag (default true) - Add JUnit 5 benchmark tests comparing SA vs classic hill climbing - Add programmatically generated test images (128x128) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bobrust/generator/HillClimbGenerator.java | 133 +++++++++-- .../java/com/bobrust/generator/Model.java | 2 +- .../com/bobrust/util/data/AppConstants.java | 3 + .../SimulatedAnnealingBenchmark.java | 211 ++++++++++++++++++ .../bobrust/generator/TestImageGenerator.java | 117 ++++++++++ src/test/resources/test-images/edges.png | Bin 0 -> 1733 bytes src/test/resources/test-images/gradient.png | Bin 0 -> 815 bytes src/test/resources/test-images/nature.png | Bin 0 -> 1220 bytes .../resources/test-images/photo_detail.png | Bin 0 -> 25783 bytes src/test/resources/test-images/solid.png | Bin 0 -> 392 bytes 10 files changed, 450 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/bobrust/generator/SimulatedAnnealingBenchmark.java create mode 100644 src/test/java/com/bobrust/generator/TestImageGenerator.java create mode 100644 src/test/resources/test-images/edges.png create mode 100644 src/test/resources/test-images/gradient.png create mode 100644 src/test/resources/test-images/nature.png create mode 100644 src/test/resources/test-images/photo_detail.png create mode 100644 src/test/resources/test-images/solid.png diff --git a/src/main/java/com/bobrust/generator/HillClimbGenerator.java b/src/main/java/com/bobrust/generator/HillClimbGenerator.java index fc099b3..0723999 100644 --- a/src/main/java/com/bobrust/generator/HillClimbGenerator.java +++ b/src/main/java/com/bobrust/generator/HillClimbGenerator.java @@ -2,8 +2,8 @@ import com.bobrust.util.data.AppConstants; -import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ThreadLocalRandom; class HillClimbGenerator { private static State getBestRandomState(List random_states) { @@ -14,35 +14,39 @@ private static State getBestRandomState(List random_states) { state.shape.randomize(); } random_states.parallelStream().forEach(State::getEnergy); - + float bestEnergy = 0; State bestState = null; for (int i = 0; i < len; i++) { State state = random_states.get(i); float energy = state.getEnergy(); - + if (bestState == null || energy < bestEnergy) { bestEnergy = energy; bestState = state; } } - + return bestState; } - - public static State getHillClimb(State state, int maxAge) { + + /** + * Original hill climbing implementation. Kept as fallback when + * {@link AppConstants#USE_SIMULATED_ANNEALING} is false. + */ + public static State getHillClimbClassic(State state, int maxAge) { float minimumEnergy = state.getEnergy(); - + // Prevent infinite recursion int maxLoops = 4096; - + State undo = state.getCopy(); - + // This function will minimize the energy of the input state for (int i = 0; i < maxAge && (maxLoops-- > 0); i++) { state.doMove(undo); float energy = state.getEnergy(); - + if (energy >= minimumEnergy) { state.fromValues(undo); } else { @@ -50,23 +54,122 @@ public static State getHillClimb(State state, int maxAge) { i = -1; } } - + if (maxLoops <= 0 && AppConstants.DEBUG_GENERATOR) { AppConstants.LOGGER.warn("HillClimbGenerator failed to find a better shape after {} tries", 4096); } - + return state; } - + + /** + * Simulated annealing implementation that can escape local minima by + * probabilistically accepting worse moves early in the search. + */ + public static State getHillClimbSA(State state, int maxAge) { + float currentEnergy = state.getEnergy(); + State bestState = state.getCopy(); + float bestEnergy = currentEnergy; + + // Estimate initial temperature from sample mutations + float temperature = estimateTemperature(state); + int totalIterations = maxAge * 10; + float coolingRate = computeCoolingRate(temperature, maxAge); + + State undo = state.getCopy(); + + for (int i = 0; i < totalIterations; i++) { + state.doMove(undo); + float newEnergy = state.getEnergy(); + float delta = newEnergy - currentEnergy; + + if (delta < 0) { + // Improvement — always accept + currentEnergy = newEnergy; + if (currentEnergy < bestEnergy) { + bestEnergy = currentEnergy; + bestState = state.getCopy(); + } + } else if (temperature > 0.001f) { + // Worse move — accept with probability exp(-delta/T) + double acceptProb = Math.exp(-delta / temperature); + if (ThreadLocalRandom.current().nextDouble() < acceptProb) { + currentEnergy = newEnergy; + } else { + state.fromValues(undo); + } + } else { + state.fromValues(undo); + } + + temperature *= coolingRate; + } + + // Return the best state found during the entire SA run + return bestState; + } + + /** + * Dispatches to SA or classic hill climbing based on the feature flag. + */ + public static State getHillClimb(State state, int maxAge) { + if (AppConstants.USE_SIMULATED_ANNEALING) { + return getHillClimbSA(state, maxAge); + } else { + return getHillClimbClassic(state, maxAge); + } + } + + /** + * Estimate a good starting temperature by sampling random mutations and + * measuring average energy deltas. Sets T so that roughly 60% of uphill + * moves are accepted at the start. + */ + static float estimateTemperature(State state) { + State probe = state.getCopy(); + State undo = probe.getCopy(); + float totalDelta = 0; + int samples = 30; + + for (int i = 0; i < samples; i++) { + float before = probe.getEnergy(); + probe.doMove(undo); + float after = probe.getEnergy(); + totalDelta += Math.abs(after - before); + probe.fromValues(undo); // restore + } + + float avgDelta = totalDelta / samples; + // Set T so ~60% of uphill moves are accepted initially + // P = exp(-avgDelta / T) = 0.6 => T = -avgDelta / ln(0.6) + // -1/ln(0.6) ≈ 1.957, but we use avgDelta / 0.5108 which is equivalent + return (float) (avgDelta / 0.5108); + } + + /** + * Compute the geometric cooling rate so that temperature decays from + * {@code initialTemp} to near-zero (0.001) over {@code maxAge * 10} iterations. + */ + static float computeCoolingRate(float initialTemp, int maxAge) { + int totalIterations = maxAge * 10; + float finalTemp = 0.001f; + if (initialTemp <= finalTemp) { + return 0.99f; // fallback if temperature is already tiny + } + // initialTemp * rate^totalIterations = finalTemp + // rate = (finalTemp / initialTemp) ^ (1 / totalIterations) + return (float) Math.pow(finalTemp / initialTemp, 1.0 / totalIterations); + } + public static State getBestHillClimbState(List random_states, int age, int times) { float bestEnergy = 0; State bestState = null; - + for (int i = 0; i < times; i++) { State oldState = getBestRandomState(random_states); State state = getHillClimb(oldState, age); float energy = state.getEnergy(); - + if (i == 0 || bestEnergy > energy) { bestEnergy = energy; bestState = state.getCopy(); diff --git a/src/main/java/com/bobrust/generator/Model.java b/src/main/java/com/bobrust/generator/Model.java index 37bf9ff..4b76c55 100644 --- a/src/main/java/com/bobrust/generator/Model.java +++ b/src/main/java/com/bobrust/generator/Model.java @@ -56,7 +56,7 @@ private void addShape(Circle shape) { private static final int max_random_states = 1000; private static final int age = 100; - private static final int times = 1; + private static final int times = Math.max(1, Runtime.getRuntime().availableProcessors() / 2); private List randomStates; diff --git a/src/main/java/com/bobrust/util/data/AppConstants.java b/src/main/java/com/bobrust/util/data/AppConstants.java index a43df02..6867178 100644 --- a/src/main/java/com/bobrust/util/data/AppConstants.java +++ b/src/main/java/com/bobrust/util/data/AppConstants.java @@ -24,6 +24,9 @@ public interface AppConstants { boolean DEBUG_DRAWN_COLORS = false; boolean DEBUG_TIME = false; int MAX_SORT_GROUP = 1000; // Max 1000 elements per sort + + // When true, use simulated annealing instead of pure hill climbing for shape optimization + boolean USE_SIMULATED_ANNEALING = true; // Average canvas colors. Used as default colors Color CANVAS_AVERAGE = new Color(0xb3aba0); diff --git a/src/test/java/com/bobrust/generator/SimulatedAnnealingBenchmark.java b/src/test/java/com/bobrust/generator/SimulatedAnnealingBenchmark.java new file mode 100644 index 0000000..bc91d62 --- /dev/null +++ b/src/test/java/com/bobrust/generator/SimulatedAnnealingBenchmark.java @@ -0,0 +1,211 @@ +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.awt.image.DataBufferInt; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import javax.imageio.ImageIO; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Benchmark and correctness tests for the simulated annealing optimizer. + * + * These tests compare SA against the classic hill climbing to verify that + * SA produces equal or better results without significant performance regression. + */ +class SimulatedAnnealingBenchmark { + private static final int ALPHA = 128; + private static final int BACKGROUND = 0xFFFFFFFF; + + /** + * Run the generator for the given number of shapes using either SA or classic hill climbing. + * Returns the final model score (lower is better). + */ + private static float runGenerator(BufferedImage testImage, int maxShapes, boolean useSimulatedAnnealing) { + // Ensure the image has TYPE_INT_ARGB so DataBufferInt works + BufferedImage argbImage = ensureArgb(testImage); + BorstImage target = new BorstImage(argbImage); + Model model = new Model(target, BACKGROUND, ALPHA); + + // Temporarily override the SA flag by calling the appropriate method directly + for (int i = 0; i < maxShapes; i++) { + Worker worker = getWorker(model); + worker.init(model.current, model.score); + List randomStates = createRandomStates(worker, 200); + State state; + if (useSimulatedAnnealing) { + State best = getBestRandomState(randomStates); + state = HillClimbGenerator.getHillClimbSA(best, 100); + } else { + State best = getBestRandomState(randomStates); + state = HillClimbGenerator.getHillClimbClassic(best, 100); + } + addShapeToModel(model, state.shape); + } + return model.score; + } + + /** Ensure image is TYPE_INT_ARGB */ + 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; + } + + /** Reflective helper to get the worker from Model (package-private field) */ + 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(e); + } + } + + /** Create a list of random states for the given worker */ + private static List createRandomStates(Worker worker, int count) { + List states = new ArrayList<>(); + for (int i = 0; i < count; i++) { + states.add(new State(worker)); + } + return states; + } + + /** Get the best random state from a list */ + private static State getBestRandomState(List states) { + for (State s : states) { + s.score = -1; + s.shape.randomize(); + } + states.parallelStream().forEach(State::getEnergy); + float bestEnergy = Float.MAX_VALUE; + State bestState = null; + for (State s : states) { + float energy = s.getEnergy(); + if (bestState == null || energy < bestEnergy) { + bestEnergy = energy; + bestState = s; + } + } + return bestState; + } + + /** Add a shape to the model using its internal addShape logic */ + private static void addShapeToModel(Model model, Circle shape) { + try { + var method = Model.class.getDeclaredMethod("addShape", Circle.class); + method.setAccessible(true); + method.invoke(model, shape); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + // ---- Test 3: Temperature Schedule Validation ---- + + @Test + void testTemperatureSchedule() { + BufferedImage img = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); + // Fill with a non-trivial pattern so energy deltas are meaningful + Graphics2D g = img.createGraphics(); + g.setColor(Color.RED); + g.fillOval(10, 10, 40, 40); + g.dispose(); + + BorstImage target = new BorstImage(img); + Worker worker = new Worker(target, ALPHA); + BorstImage current = new BorstImage(64, 64); + Arrays.fill(current.pixels, BACKGROUND); + float initialScore = BorstCore.differenceFull(target, current); + worker.init(current, initialScore); + State state = new State(worker); + state.getEnergy(); + + float temp = HillClimbGenerator.estimateTemperature(state); + + // Temperature should be positive and finite + assertTrue(temp > 0, "Temperature should be positive, got " + temp); + assertTrue(Float.isFinite(temp), "Temperature should be finite"); + + // Cooling rate should be between 0 and 1 + float rate = HillClimbGenerator.computeCoolingRate(temp, 100); + assertTrue(rate > 0 && rate < 1, "Cooling rate should be in (0,1), got " + rate); + + // After maxAge*10 iterations, temperature should be near zero + float finalTemp = temp; + for (int i = 0; i < 1000; i++) finalTemp *= rate; + assertTrue(finalTemp < 0.01f, "Final temperature should be near zero, got " + finalTemp); + } + + // ---- Test 1: SA Produces Lower or Equal Energy ---- + + @Test + void testSAProducesLowerOrEqualEnergy() { + BufferedImage testImage = TestImageGenerator.createPhotoDetail(); + int maxShapes = 50; // Small count for test speed + + float hillClimbScore = runGenerator(testImage, maxShapes, false); + float saScore = runGenerator(testImage, maxShapes, true); + + System.out.println("Hill climb score: " + hillClimbScore); + System.out.println("SA score: " + saScore); + float improvement = (hillClimbScore - saScore) / hillClimbScore * 100; + System.out.println("SA improvement: " + improvement + "%"); + + // SA should not be dramatically worse (allow 5% tolerance due to stochastic nature) + assertTrue(saScore <= hillClimbScore * 1.05f, + "SA score (" + saScore + ") should not be significantly worse than hill climb (" + hillClimbScore + ")"); + } + + // ---- Test 4: Regression — No Worse Than Baseline on Multiple Images ---- + + @Test + void testSANeverSignificantlyWorse() { + BufferedImage[] images = { + TestImageGenerator.createSolid(), + TestImageGenerator.createGradient(), + TestImageGenerator.createEdges(), + }; + String[] names = {"solid", "gradient", "edges"}; + int maxShapes = 30; // Small for test speed + + for (int idx = 0; idx < images.length; idx++) { + float hcScore = runGenerator(images[idx], maxShapes, false); + float saScore = runGenerator(images[idx], maxShapes, true); + System.out.println(names[idx] + " — HC: " + hcScore + ", SA: " + saScore); + + // SA should never be more than 5% worse (stochastic tolerance) + assertTrue(saScore <= hcScore * 1.05f, + names[idx] + ": SA (" + saScore + ") should not be significantly worse than HC (" + hcScore + ")"); + } + } + + // ---- Test: Cooling Rate Edge Cases ---- + + @Test + void testCoolingRateEdgeCases() { + // Very small initial temperature + float rate = HillClimbGenerator.computeCoolingRate(0.0005f, 100); + assertTrue(rate > 0 && rate <= 1.0f, "Cooling rate should handle small temps, got " + rate); + + // Large initial temperature + rate = HillClimbGenerator.computeCoolingRate(1000f, 100); + assertTrue(rate > 0 && rate < 1, "Cooling rate should handle large temps, got " + rate); + + // Normal case + rate = HillClimbGenerator.computeCoolingRate(1.0f, 100); + assertTrue(rate > 0 && rate < 1, "Cooling rate should be in (0,1), got " + rate); + } +} diff --git a/src/test/java/com/bobrust/generator/TestImageGenerator.java b/src/test/java/com/bobrust/generator/TestImageGenerator.java new file mode 100644 index 0000000..0340c0f --- /dev/null +++ b/src/test/java/com/bobrust/generator/TestImageGenerator.java @@ -0,0 +1,117 @@ +package com.bobrust.generator; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; + +/** + * Utility to programmatically generate test images for benchmarks. + * All images are 128x128 to keep test times reasonable. + */ +class TestImageGenerator { + static final int SIZE = 128; + + /** Solid red image */ + static BufferedImage createSolid() { + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setColor(new Color(200, 50, 50)); + g.fillRect(0, 0, SIZE, SIZE); + g.dispose(); + return img; + } + + /** Horizontal gradient from blue to green */ + static BufferedImage createGradient() { + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + for (int x = 0; x < SIZE; x++) { + float t = x / (float) (SIZE - 1); + int r = (int) (50 * (1 - t) + 50 * t); + int g = (int) (50 * (1 - t) + 200 * t); + int b = (int) (200 * (1 - t) + 50 * t); + int rgb = 0xFF000000 | (r << 16) | (g << 8) | b; + for (int y = 0; y < SIZE; y++) { + img.setRGB(x, y, rgb); + } + } + return img; + } + + /** High-contrast black/white edges — checkerboard pattern */ + static BufferedImage createEdges() { + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + int blockSize = 16; + for (int y = 0; y < SIZE; y++) { + for (int x = 0; x < SIZE; x++) { + boolean white = ((x / blockSize) + (y / blockSize)) % 2 == 0; + img.setRGB(x, y, white ? 0xFFFFFFFF : 0xFF000000); + } + } + return img; + } + + /** Simulated photo with fine detail — concentric circles of varying colors */ + static BufferedImage createPhotoDetail() { + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + int cx = SIZE / 2; + int cy = SIZE / 2; + for (int y = 0; y < SIZE; y++) { + for (int x = 0; x < SIZE; x++) { + double dist = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)); + int r = (int) (127 + 127 * Math.sin(dist * 0.3)); + int g = (int) (127 + 127 * Math.cos(dist * 0.5)); + int b = (int) (127 + 127 * Math.sin(dist * 0.7 + 1.0)); + img.setRGB(x, y, 0xFF000000 | (r << 16) | (g << 8) | b); + } + } + return img; + } + + /** Natural scene approximation — overlapping gradients and shapes */ + static BufferedImage createNature() { + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + // Sky gradient + for (int y = 0; y < SIZE / 2; y++) { + float t = y / (float) (SIZE / 2); + g.setColor(new Color( + (int) (100 + 100 * t), + (int) (150 + 50 * t), + (int) (255 - 50 * t) + )); + g.drawLine(0, y, SIZE - 1, y); + } + // Ground + g.setColor(new Color(80, 140, 50)); + g.fillRect(0, SIZE / 2, SIZE, SIZE / 2); + // Tree trunk + g.setColor(new Color(100, 70, 30)); + g.fillRect(55, 40, 18, 50); + // Tree canopy + g.setColor(new Color(30, 120, 30)); + g.fillOval(30, 10, 68, 50); + // Sun + g.setColor(new Color(255, 230, 80)); + g.fillOval(90, 5, 30, 30); + g.dispose(); + return img; + } + + /** Save all test images to the given directory */ + static void saveAll(File dir) throws IOException { + dir.mkdirs(); + ImageIO.write(createSolid(), "png", new File(dir, "solid.png")); + ImageIO.write(createGradient(), "png", new File(dir, "gradient.png")); + ImageIO.write(createEdges(), "png", new File(dir, "edges.png")); + ImageIO.write(createPhotoDetail(), "png", new File(dir, "photo_detail.png")); + ImageIO.write(createNature(), "png", new File(dir, "nature.png")); + } + + public static void main(String[] args) throws IOException { + File dir = new File("src/test/resources/test-images"); + saveAll(dir); + System.out.println("Test images generated in " + dir.getAbsolutePath()); + } +} diff --git a/src/test/resources/test-images/edges.png b/src/test/resources/test-images/edges.png new file mode 100644 index 0000000000000000000000000000000000000000..3b89f77eedc9fff9df3f722412bb203ac5daccb4 GIT binary patch literal 1733 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVC(U8aSW-5TY7FI?*Rv%!w&EF zms|=KozHY*hsBS5J9c|ty|@{SE(>mv4hVBU4@Um(7xEk;Wo@IuNY-&TV`}M_955%lp6vPn+~)?0vt#V@-QdTd3)QREm*ZU zeKiDGH*K@3f}&l@8BkH6+z^n^`Jf2N89;I|YHLl@Liw;8yG8${x$cRYs2_G^ zv*_O^2WGfye>>Q*X2U28w;WiPrs>&=E`x7NNi%U*VMR_?|+)9C%P z*X(r5-ghu+#dfjRYnDc3?>rW@a$DHyHCw&1_a6EcocC9Evzgc0vqIUcZ`{(`RK~UT zE>pJt{afcZ^A)Ws&7-A(%S1BuXXQK@o%J6{} literal 0 HcmV?d00001 diff --git a/src/test/resources/test-images/nature.png b/src/test/resources/test-images/nature.png new file mode 100644 index 0000000000000000000000000000000000000000..674423b31bf68b66007081c6b118414509e6bc09 GIT binary patch literal 1220 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 bEvo4Q1>yku6+Pk)N&cszpebJ{YZmx_CgBha literal 0 HcmV?d00001 diff --git a/src/test/resources/test-images/solid.png b/src/test/resources/test-images/solid.png new file mode 100644 index 0000000000000000000000000000000000000000..f4444c73837eac3d19017136474db00190206272 GIT binary patch literal 392 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVD$HNaSW-5dwa={kwJmyz=q6O z^^7~3