From 0a476a337c180b004bac40ebb5ec32f362592a8f Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Sat, 4 Apr 2026 21:41:03 +0000 Subject: [PATCH] Add 2-opt TSP paint order optimization on top of greedy sorter (Proposal 5) Adds TwoOptOptimizer that applies 2-opt local search to the greedy BorstSorter output, minimizing a unified cost function of palette changes and cursor travel distance. Integrated into BorstSorter.sort() when USE_TSP_OPTIMIZATION is true. Tunable weights: TSP_W_PALETTE=3.0, TSP_W_DISTANCE=1.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bobrust/generator/sorter/BorstSorter.java | 10 +- .../generator/sorter/TwoOptOptimizer.java | 140 +++++++++++ .../com/bobrust/util/data/AppConstants.java | 8 + .../generator/TwoOptOptimizerTest.java | 221 ++++++++++++++++++ 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/bobrust/generator/sorter/TwoOptOptimizer.java create mode 100644 src/test/java/com/bobrust/generator/TwoOptOptimizerTest.java diff --git a/src/main/java/com/bobrust/generator/sorter/BorstSorter.java b/src/main/java/com/bobrust/generator/sorter/BorstSorter.java index 4ba6064..bddd16c 100644 --- a/src/main/java/com/bobrust/generator/sorter/BorstSorter.java +++ b/src/main/java/com/bobrust/generator/sorter/BorstSorter.java @@ -150,12 +150,20 @@ public static BlobList sort(BlobList data, int size) { blobs.addAll(Arrays.asList(sort0(pieces, size, localMap))); } + BlobList result = new BlobList(blobs); + + // Apply 2-opt local search to reduce palette changes + travel distance + if (AppConstants.USE_TSP_OPTIMIZATION && result.size() > 2) { + TwoOptOptimizer optimizer = new TwoOptOptimizer(size, size); + result = optimizer.optimize(result); + } + if (AppConstants.DEBUG_TIME) { long time = System.nanoTime() - start; AppConstants.LOGGER.info("BorstSorter.sort(data, size) took {} ms for {} shapes", time / 1000000.0, data.size()); } - return new BlobList(blobs); + return result; } private static Blob[] sort0(Piece[] array, int size, IntList[] map) { diff --git a/src/main/java/com/bobrust/generator/sorter/TwoOptOptimizer.java b/src/main/java/com/bobrust/generator/sorter/TwoOptOptimizer.java new file mode 100644 index 0000000..f82248f --- /dev/null +++ b/src/main/java/com/bobrust/generator/sorter/TwoOptOptimizer.java @@ -0,0 +1,140 @@ +package com.bobrust.generator.sorter; + +import com.bobrust.util.data.AppConstants; + +/** + * 2-opt local search optimizer for paint ordering. + * + * Takes the greedy output from {@link BorstSorter} and applies 2-opt + * improvements to reduce total cost, which is a weighted sum of palette + * changes and Euclidean cursor travel distance. + * + * The cost function: + * cost(a, b) = W_palette * paletteChanges(a, b) + W_distance * normalizedDistance(a, b) + */ +public class TwoOptOptimizer { + + /** + * The maximum Euclidean distance used for normalization. + * For a typical sign this is the diagonal of the canvas. + */ + private final float maxDistance; + + public TwoOptOptimizer(int canvasWidth, int canvasHeight) { + this.maxDistance = (float) Math.sqrt( + (double) canvasWidth * canvasWidth + (double) canvasHeight * canvasHeight); + } + + /** + * Compute the cost of transitioning from blob a to blob b. + */ + public double cost(Blob a, Blob b) { + int paletteChanges = countPaletteChanges(a, b); + float distance = euclideanDistance(a, b); + float normalizedDist = (maxDistance > 0) ? distance / maxDistance : 0; + + return AppConstants.TSP_W_PALETTE * paletteChanges + + AppConstants.TSP_W_DISTANCE * normalizedDist; + } + + /** + * Count the number of palette interaction changes between two consecutive blobs. + * Each differing attribute (size, color, alpha, shape) requires a click action. + */ + public static int countPaletteChanges(Blob a, Blob b) { + int changes = 0; + if (a.sizeIndex != b.sizeIndex) changes++; + if (a.colorIndex != b.colorIndex) changes++; + if (a.alphaIndex != b.alphaIndex) changes++; + if (a.shapeIndex != b.shapeIndex) changes++; + return changes; + } + + /** + * Euclidean distance between the centers of two blobs. + */ + public static float euclideanDistance(Blob a, Blob b) { + int dx = a.x - b.x; + int dy = a.y - b.y; + return (float) Math.sqrt((double) dx * dx + (double) dy * dy); + } + + /** + * Compute the total route cost for an ordered array of blobs. + */ + public double totalCost(Blob[] blobs) { + if (blobs.length <= 1) return 0; + double total = 0; + for (int i = 0; i < blobs.length - 1; i++) { + total += cost(blobs[i], blobs[i + 1]); + } + return total; + } + + /** + * Apply 2-opt local search to improve the ordering. + * For each pair of edges (i, i+1) and (j, j+1), checks if reversing + * the segment [i+1..j] reduces total cost. + * + * @param blobs the ordered blob array to optimize in-place + * @return the optimized array (same reference) + */ + public Blob[] optimize(Blob[] blobs) { + if (blobs.length <= 3) return blobs; + + boolean improved = true; + int maxIterations = 100; // Safety limit to prevent excessive optimization time + int iteration = 0; + + while (improved && iteration < maxIterations) { + improved = false; + iteration++; + + for (int i = 0; i < blobs.length - 2; i++) { + for (int j = i + 2; j < blobs.length - 1; j++) { + // Current edges: (i, i+1) and (j, j+1) + // Proposed: reverse segment [i+1..j] + // New edges: (i, j) and (i+1, j+1) + double oldCost = cost(blobs[i], blobs[i + 1]) + + cost(blobs[j], blobs[j + 1]); + double newCost = cost(blobs[i], blobs[j]) + + cost(blobs[i + 1], blobs[j + 1]); + + if (newCost < oldCost - 1e-10) { + // Reverse the segment [i+1..j] + reverse(blobs, i + 1, j); + improved = true; + } + } + } + } + + return blobs; + } + + /** + * Apply 2-opt optimization to a BlobList and return a new optimized BlobList. + */ + public BlobList optimize(BlobList sorted) { + Blob[] blobs = sorted.getList().toArray(new Blob[0]); + optimize(blobs); + BlobList result = new BlobList(); + for (Blob b : blobs) { + result.add(b); + } + return result; + } + + /** + * Reverse the sub-array blobs[start..end] inclusive. + */ + private static void reverse(Blob[] blobs, int start, int end) { + while (start < end) { + Blob temp = blobs[start]; + blobs[start] = blobs[end]; + blobs[end] = temp; + start++; + end--; + } + } +} diff --git a/src/main/java/com/bobrust/util/data/AppConstants.java b/src/main/java/com/bobrust/util/data/AppConstants.java index 23fa8ad..ec40b60 100644 --- a/src/main/java/com/bobrust/util/data/AppConstants.java +++ b/src/main/java/com/bobrust/util/data/AppConstants.java @@ -38,6 +38,14 @@ public interface AppConstants { // When true, use batch-parallel energy evaluation with combined color+energy pass, // spatial batching for cache locality, and precomputed alpha blend tables boolean USE_BATCH_PARALLEL = true; + + // When true, apply 2-opt local search on top of greedy BorstSorter output + // to reduce total cost (palette changes + cursor travel distance) + boolean USE_TSP_OPTIMIZATION = true; + + // 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 // Average canvas colors. Used as default colors Color CANVAS_AVERAGE = new Color(0xb3aba0); diff --git a/src/test/java/com/bobrust/generator/TwoOptOptimizerTest.java b/src/test/java/com/bobrust/generator/TwoOptOptimizerTest.java new file mode 100644 index 0000000..0f94c38 --- /dev/null +++ b/src/test/java/com/bobrust/generator/TwoOptOptimizerTest.java @@ -0,0 +1,221 @@ +package com.bobrust.generator; + +import com.bobrust.generator.sorter.Blob; +import com.bobrust.generator.sorter.BlobList; +import com.bobrust.generator.sorter.BorstSorter; +import com.bobrust.generator.sorter.TwoOptOptimizer; +import com.bobrust.util.data.AppConstants; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for Proposal 5: Paint Order Optimization with 2-opt TSP heuristic. + * + * Verifies that 2-opt reduces total cost vs. greedy-only ordering, + * preserves the same set of shapes (no shapes lost or duplicated), + * and runs in acceptable time. + */ +class TwoOptOptimizerTest { + private static final int CANVAS_SIZE = 512; + + // ---- Test 1: 2-opt reduces or maintains total cost vs greedy ---- + + @Test + void testTwoOptReducesCost() { + TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE); + Random rnd = new Random(42); + + // Generate a set of random blobs + BlobList input = generateRandomBlobs(rnd, 200); + + // Sort with greedy (BorstSorter) + BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE); + + // Extract greedy order, compute cost before 2-opt + Blob[] greedyArray = greedy.getList().toArray(new Blob[0]); + double greedyCost = optimizer.totalCost(greedyArray); + + // Apply 2-opt + Blob[] optimized = greedyArray.clone(); + optimizer.optimize(optimized); + double optimizedCost = optimizer.totalCost(optimized); + + System.out.println("Greedy cost: " + String.format("%.4f", greedyCost)); + System.out.println("2-opt cost: " + String.format("%.4f", optimizedCost)); + System.out.println("Improvement: " + String.format("%.2f%%", + (greedyCost - optimizedCost) / greedyCost * 100)); + + // 2-opt should not increase cost + assertTrue(optimizedCost <= greedyCost + 1e-6, + "2-opt should not increase cost: greedy=" + greedyCost + " optimized=" + optimizedCost); + } + + // ---- Test 2: All shapes are preserved (no duplicates, no losses) ---- + + @Test + void testAllShapesPreserved() { + TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE); + Random rnd = new Random(123); + + BlobList input = generateRandomBlobs(rnd, 100); + BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE); + + Blob[] before = greedy.getList().toArray(new Blob[0]); + Blob[] after = before.clone(); + optimizer.optimize(after); + + assertEquals(before.length, after.length, "Same number of blobs"); + + // Count occurrences by hashCode + java.util.Map beforeCounts = new java.util.HashMap<>(); + java.util.Map afterCounts = new java.util.HashMap<>(); + for (Blob b : before) beforeCounts.merge(b.hashCode(), 1, Integer::sum); + for (Blob b : after) afterCounts.merge(b.hashCode(), 1, Integer::sum); + + assertEquals(beforeCounts, afterCounts, "Same blobs before and after 2-opt"); + } + + // ---- Test 3: Cost function correctness ---- + + @Test + void testCostFunction() { + TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE); + + // Two identical blobs at the same position: cost should be 0 + Blob a = Blob.of(100, 100, 12, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE); + Blob same = Blob.of(100, 100, 12, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE); + double costSame = optimizer.cost(a, same); + assertEquals(0.0, costSame, 1e-6, "Same blob should have zero cost"); + + // Different color only + Blob diffColor = Blob.of(100, 100, 12, BorstUtils.COLORS[10].rgb, 128, AppConstants.CIRCLE_SHAPE); + double costDiffColor = optimizer.cost(a, diffColor); + assertTrue(costDiffColor > 0, "Different color should have positive cost"); + + // Different position only (same attributes) + Blob diffPos = Blob.of(400, 400, 12, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE); + double costDiffPos = optimizer.cost(a, diffPos); + assertTrue(costDiffPos > 0, "Different position should have positive cost"); + + // Different everything: should have higher cost than just one difference + Blob diffAll = Blob.of(400, 400, 50, BorstUtils.COLORS[10].rgb, 255, AppConstants.SQUARE_SHAPE); + double costDiffAll = optimizer.cost(a, diffAll); + assertTrue(costDiffAll > costDiffColor, "All-different should cost more than color-only"); + assertTrue(costDiffAll > costDiffPos, "All-different should cost more than position-only"); + } + + // ---- Test 4: Palette change counting ---- + + @Test + void testPaletteChangeCount() { + Blob a = Blob.of(50, 50, 12, BorstUtils.COLORS[0].rgb, 128, AppConstants.CIRCLE_SHAPE); + + // Same everything + Blob same = Blob.of(50, 50, 12, BorstUtils.COLORS[0].rgb, 128, AppConstants.CIRCLE_SHAPE); + assertEquals(0, TwoOptOptimizer.countPaletteChanges(a, same)); + + // Different size + Blob diffSize = Blob.of(50, 50, 50, BorstUtils.COLORS[0].rgb, 128, AppConstants.CIRCLE_SHAPE); + assertEquals(1, TwoOptOptimizer.countPaletteChanges(a, diffSize)); + + // Different color + size + Blob diffTwo = Blob.of(50, 50, 50, BorstUtils.COLORS[5].rgb, 128, AppConstants.CIRCLE_SHAPE); + assertEquals(2, TwoOptOptimizer.countPaletteChanges(a, diffTwo)); + + // All different + Blob diffAll = Blob.of(50, 50, 50, BorstUtils.COLORS[5].rgb, 255, AppConstants.SQUARE_SHAPE); + assertEquals(4, TwoOptOptimizer.countPaletteChanges(a, diffAll)); + } + + // ---- Test 5: Edge cases ---- + + @Test + void testEdgeCases() { + TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE); + + // Empty list + BlobList empty = new BlobList(); + Blob[] emptyArr = new Blob[0]; + assertEquals(0.0, optimizer.totalCost(emptyArr)); + + // Single blob + Blob[] single = { Blob.of(50, 50, 12, 0, 128, AppConstants.CIRCLE_SHAPE) }; + assertEquals(0.0, optimizer.totalCost(single)); + optimizer.optimize(single); // should not crash + + // Two blobs + Blob[] two = { + Blob.of(50, 50, 12, 0, 128, AppConstants.CIRCLE_SHAPE), + Blob.of(200, 200, 50, BorstUtils.COLORS[10].rgb, 255, AppConstants.CIRCLE_SHAPE) + }; + double cost2 = optimizer.totalCost(two); + assertTrue(cost2 > 0); + optimizer.optimize(two); // should not crash + } + + // ---- Test 6: Performance — optimization runs within time budget ---- + + @Test + void testOptimizationPerformance() { + TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE); + Random rnd = new Random(999); + + // Test with a moderate number of blobs + BlobList input = generateRandomBlobs(rnd, 500); + BlobList greedy = BorstSorter.sort(input, CANVAS_SIZE); + Blob[] blobs = greedy.getList().toArray(new Blob[0]); + + long start = System.nanoTime(); + optimizer.optimize(blobs); + long elapsed = System.nanoTime() - start; + double elapsedMs = elapsed / 1_000_000.0; + + System.out.println("2-opt on 500 blobs: " + String.format("%.2f", elapsedMs) + " ms"); + + // Should complete within 5 seconds (generous bound for CI environments) + assertTrue(elapsedMs < 5000, "2-opt should complete within 5s, took " + elapsedMs + "ms"); + } + + // ---- Test 7: Integration with BorstSorter (end-to-end) ---- + + @Test + void testIntegrationWithBorstSorter() { + Random rnd = new Random(777); + BlobList input = generateRandomBlobs(rnd, 100); + + // BorstSorter.sort should now include 2-opt when USE_TSP_OPTIMIZATION is true + BlobList sorted = BorstSorter.sort(input, CANVAS_SIZE); + + // Basic sanity: same number of blobs + assertEquals(input.size(), sorted.size(), "Sorted list should have same size as input"); + + // Compute cost + TwoOptOptimizer optimizer = new TwoOptOptimizer(CANVAS_SIZE, CANVAS_SIZE); + Blob[] sortedArr = sorted.getList().toArray(new Blob[0]); + double cost = optimizer.totalCost(sortedArr); + System.out.println("End-to-end sorted cost: " + String.format("%.4f", cost)); + + // Cost should be finite and non-negative + assertTrue(cost >= 0 && Double.isFinite(cost)); + } + + // ---- Helper: generate random blobs ---- + + private static BlobList generateRandomBlobs(Random rnd, int count) { + BlobList list = new BlobList(); + for (int i = 0; i < count; i++) { + int x = rnd.nextInt(CANVAS_SIZE); + int y = rnd.nextInt(CANVAS_SIZE); + int sizeIdx = rnd.nextInt(BorstUtils.SIZES.length); + int colorIdx = rnd.nextInt(BorstUtils.COLORS.length); + int alphaIdx = rnd.nextInt(BorstUtils.ALPHAS.length); + int shape = rnd.nextBoolean() ? AppConstants.CIRCLE_SHAPE : AppConstants.SQUARE_SHAPE; + list.add(Blob.of(x, y, BorstUtils.SIZES[sizeIdx], + BorstUtils.COLORS[colorIdx].rgb, BorstUtils.ALPHAS[alphaIdx], shape)); + } + return list; + } +}