-
Notifications
You must be signed in to change notification settings - Fork 0
Paint order optimization with 2-opt TSP (Proposal 5) #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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--; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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); | ||||||||||||||||||||||||||||||
|
Comment on lines
+34
to
+44
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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<Integer, Integer> beforeCounts = new java.util.HashMap<>(); | ||||||||||||||||||||||||||||||
| java.util.Map<Integer, Integer> 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"); | ||||||||||||||||||||||||||||||
|
Comment on lines
+72
to
+78
|
||||||||||||||||||||||||||||||
| // Count occurrences by hashCode | |
| java.util.Map<Integer, Integer> beforeCounts = new java.util.HashMap<>(); | |
| java.util.Map<Integer, Integer> 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"); | |
| // Count occurrences by object identity to verify the optimizer only permutes references | |
| java.util.Map<Blob, Integer> beforeCounts = new java.util.IdentityHashMap<>(); | |
| java.util.Map<Blob, Integer> afterCounts = new java.util.IdentityHashMap<>(); | |
| for (Blob b : before) beforeCounts.merge(b, 1, Integer::sum); | |
| for (Blob b : after) afterCounts.merge(b, 1, Integer::sum); | |
| assertEquals(beforeCounts, afterCounts, "Same blob references before and after 2-opt"); |
Copilot
AI
Apr 4, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The performance benchmark times optimizer.optimize(blobs), but the blobs are obtained via BorstSorter.sort(...), which now applies 2-opt already. This means you're measuring a second optimization pass on an already locally-optimized route and may substantially underreport runtime. For a representative benchmark, build the input route without 2-opt (greedy-only) before timing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BorstSorter.sort()now runs 2-opt on the entire concatenated result list. Because the 2-opt implementation is O(n²) per iteration, this can become prohibitively slow (or effectively hang) whendata.size()is large (the sorter already chunks work viaMAX_SORT_GROUP, but this optimization ignores that and reintroduces a quadratic pass over all blobs). Consider capping optimization to a maximum N, running it per chunk/window, or making optimization opt-in at call sites for large runs.