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 0000000..3b89f77 Binary files /dev/null and b/src/test/resources/test-images/edges.png differ diff --git a/src/test/resources/test-images/gradient.png b/src/test/resources/test-images/gradient.png new file mode 100644 index 0000000..df1b557 Binary files /dev/null and b/src/test/resources/test-images/gradient.png differ diff --git a/src/test/resources/test-images/nature.png b/src/test/resources/test-images/nature.png new file mode 100644 index 0000000..674423b Binary files /dev/null and b/src/test/resources/test-images/nature.png differ diff --git a/src/test/resources/test-images/photo_detail.png b/src/test/resources/test-images/photo_detail.png new file mode 100644 index 0000000..9065aaf Binary files /dev/null and b/src/test/resources/test-images/photo_detail.png differ diff --git a/src/test/resources/test-images/solid.png b/src/test/resources/test-images/solid.png new file mode 100644 index 0000000..f4444c7 Binary files /dev/null and b/src/test/resources/test-images/solid.png differ