From ff9cdc173fa88989203609326788eb5d567e0e16 Mon Sep 17 00:00:00 2001 From: Guilherme Date: Mon, 31 Aug 2026 12:10:15 +0200 Subject: [PATCH] Colour coverage per cutter, shaded by how often it passed The swept area was one flat green wash, which answers neither "who covered this?" nor "how much effort went here?". Each cutter now fills the area it sweeps in its own colour -- the same one its tour is drawn in -- graded by how often it passed over each cell: washed out towards white at one pass, deepened past its colour towards black at the busiest. All cutters share one scale, so a shade means the same number of passes whoever drew it. Counting placements needs multiplicities, which a boolean mask cannot hold, so `grid.py` gains the counting twin of what it already had: - `CellCounts`, laid out exactly like `CellSet` - `dilate_counts`, mirroring `dilate`; it reuses `_horizontal_runs` and the prefix-sum trick of `_dilate_horizontally`, summing a window per run instead of OR-ing it, so the cost still does not depend on run length - `_blit_counts` beside `_blit`, sharing the clipping via `_overlap` Two details the counts have to get right, both silent if wrong: - a tour closes onto its own start, so each edge contributes its start but not its end; a lap then sums to the tour's length - `_walk` pads a short tour by repeating its last position so every cutter animates to the same length. Boolean coverage did not care; counts would have piled hundreds of phantom passes onto a parked cutter's spot, so each tour counts only its own steps. `SolutionValidator._anchors` becomes public as `reachable_anchors`: both the plot and the animation have to drop exactly the placements the verifier drops, and duplicating that clipping would let the picture drift from the verdict it is captioned with. The animation shades the same way and its last frame leaves exactly the counts the plot draws, which is asserted directly. Verified against a brute-force count -- walk every step, stamp every cutter cell -- over self-crossing tours, stationary cutters, off-centre cutters, regions with holes, and excursions outside the region's reach. Co-Authored-By: Claude Opus 5 --- README.md | 19 +- src/cgshop2027_pyutils/grid.py | 135 ++++++++- src/cgshop2027_pyutils/verify.py | 8 +- src/cgshop2027_pyutils/visualize.py | 407 +++++++++++++++++++++------- tests/test_grid.py | 87 +++++- tests/test_visualize.py | 275 ++++++++++++++++++- 6 files changed, 815 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 90908ed..6e55788 100644 --- a/README.md +++ b/README.md @@ -138,10 +138,17 @@ animation = create_solution_animation(instance, solution) animation.save("solution.gif", writer="pillow") ``` -The solution plot shades the swept area, marks any cells left uncovered in red -and draws one tour per cutter. The animation strides through the tours to fit -`max_frames` (300 by default), filling in coverage as the cutters move; pass -`step` to control the stride yourself. +The solution plot draws one tour per cutter and marks any cells left uncovered +in red. Each cutter shades the area it sweeps in its own colour, graded by how +often it passed over each cell: washed out towards white where it went by once, +deepened towards black over the cell it worked hardest. All cutters share one +scale, so the same shade means the same number of passes whichever cutter drew +it, and where two of them sweep the same cell their fills blend. + +The animation strides through the tours to fit `max_frames` (300 by default), +filling in coverage as the cutters move; pass `step` to control the stride +yourself. It shades exactly as the plot does, and its last frame leaves the +counts the plot draws. All three take `bare=True`, which drops the axes, the grid and every title and trims the figure to the drawing, for a picture meant for a paper or a slide @@ -194,6 +201,10 @@ Issues & PRs welcome. Please: ## Changelog +- **Unreleased**: Coverage in the solution plot and the animation is drawn per + cutter and shaded by how often each cell was passed over, rather than as one + flat green area. Adds `CellCounts` and `dilate_counts` to `grid.py` and makes + `SolutionValidator.reachable_anchors` public. - **0.1.0** (2026-08-25): First release. Instance and solution schemas, the solution verifier, archive reading and writing, the instance database, and plotting and animation. diff --git a/src/cgshop2027_pyutils/grid.py b/src/cgshop2027_pyutils/grid.py index 55bf812..5f51472 100644 --- a/src/cgshop2027_pyutils/grid.py +++ b/src/cgshop2027_pyutils/grid.py @@ -18,7 +18,14 @@ from .schemas.instance import PointSequence, PolyominoWithHoles -__all__ = ["CellSet", "dilate", "rasterize", "rasterize_ring"] +__all__ = [ + "CellCounts", + "CellSet", + "dilate", + "dilate_counts", + "rasterize", + "rasterize_ring", +] @dataclass(frozen=True) @@ -116,6 +123,44 @@ def to_polygons(self): return unary_union([box(x, y, x + 1, y + 1) for x, y in self.cells()]) +@dataclass(frozen=True) +class CellCounts: + """ + A multiplicity for every cell of a bounding box: not whether something + covers the cell, but how often it does. + + Laid out exactly like `CellSet` -- `counts[row, col]` is the cell at + `(origin[0] + col, origin[1] + row)` -- so the two index alike and + `nonzero()` reads the box back as the cell set it counts over. + """ + + counts: np.ndarray + origin: tuple[int, int] + + @property + def width(self) -> int: + return int(self.counts.shape[1]) + + @property + def height(self) -> int: + return int(self.counts.shape[0]) + + @property + def bounds(self) -> tuple[int, int, int, int]: + """As `CellSet.bounds`: the last cell that fits, not one past it.""" + x, y = self.origin + return (x, y, x + self.width - 1, y + self.height - 1) + + @property + def maximum(self) -> int: + """The highest count on the grid, or 0 if there is nothing on it.""" + return int(self.counts.max()) if self.counts.size else 0 + + def nonzero(self) -> CellSet: + """The cells counted at least once, over the same box.""" + return CellSet(self.counts > 0, self.origin) + + def _combine(a: CellSet, b: CellSet, op) -> CellSet: """ Applies a binary boolean operation to two cell sets over the union of their @@ -142,16 +187,51 @@ def _blit( """ ORs `source` into `target`, clipping whatever falls outside. """ + overlap = _overlap(target, target_origin, source, source_origin) + if overlap is None: + return + into, out_of = overlap + target[into] |= source[out_of] + + +def _blit_counts( + target: np.ndarray, + target_origin: tuple[int, int], + source: np.ndarray, + source_origin: tuple[int, int], +) -> None: + """ + Adds `source` into `target`, clipping whatever falls outside; the + counting twin of `_blit`. + """ + overlap = _overlap(target, target_origin, source, source_origin) + if overlap is None: + return + into, out_of = overlap + target[into] += source[out_of] + + +def _overlap( + target: np.ndarray, + target_origin: tuple[int, int], + source: np.ndarray, + source_origin: tuple[int, int], +) -> tuple[tuple[slice, slice], tuple[slice, slice]] | None: + """ + Where the two grids meet, as the slice of each; `None` if they miss each + other entirely. + """ dx = source_origin[0] - target_origin[0] dy = source_origin[1] - target_origin[1] height, width = source.shape rows = slice(max(dy, 0), min(dy + height, target.shape[0])) cols = slice(max(dx, 0), min(dx + width, target.shape[1])) if rows.start >= rows.stop or cols.start >= cols.stop: - return - target[rows, cols] |= source[ - rows.start - dy : rows.stop - dy, cols.start - dx : cols.stop - dx - ] + return None + return (rows, cols), ( + slice(rows.start - dy, rows.stop - dy), + slice(cols.start - dx, cols.stop - dx), + ) def rasterize_ring(ring: PointSequence) -> CellSet: @@ -250,3 +330,48 @@ def dilate(cells: CellSet, structuring: CellSet) -> CellSet: col = x - structuring.origin[0] result[row : row + cells.height, col : col + swept.shape[1]] |= swept return CellSet(result, (x_min, y_min)) + + +def _sum_horizontally(counts: np.ndarray, length: int) -> np.ndarray: + """ + The sum of `counts` shifted east by `0 .. length - 1`, via a prefix sum + so that the cost does not depend on `length`. + + The counting twin of `_dilate_horizontally`: where that one asks whether + any of the shifted copies covers a cell, this one adds up how many do. + """ + height, width = counts.shape + prefix = np.zeros((height, width + 1), dtype=np.int64) + np.cumsum(counts, axis=1, dtype=np.int64, out=prefix[:, 1:]) + columns = np.arange(width + length - 1) + high = np.minimum(columns + 1, width) + low = np.maximum(columns - length + 1, 0) + return prefix[:, high] - prefix[:, low] + + +def dilate_counts(cells: CellCounts, structuring: CellSet) -> CellCounts: + """ + `dilate` with multiplicities: each cell of the result counts the + placements of `structuring` covering it, so a cell that two placements + reach comes out as 2 where `dilate` would only say `True`. + + The runs of `structuring` partition its cells, so adding one window sum + per run counts every placement exactly once. + """ + height, width = cells.counts.shape + result = np.zeros( + (height + structuring.height - 1, width + structuring.width - 1), + dtype=np.int64, + ) + for x, y, length in _horizontal_runs(structuring): + summed = _sum_horizontally(cells.counts, length) + row = y - structuring.origin[1] + col = x - structuring.origin[0] + result[row : row + height, col : col + summed.shape[1]] += summed + return CellCounts( + result, + ( + cells.origin[0] + structuring.origin[0], + cells.origin[1] + structuring.origin[1], + ), + ) diff --git a/src/cgshop2027_pyutils/verify.py b/src/cgshop2027_pyutils/verify.py index 1d42809..1d7d117 100644 --- a/src/cgshop2027_pyutils/verify.py +++ b/src/cgshop2027_pyutils/verify.py @@ -64,7 +64,7 @@ def swept_cells(self, solution: CGSHOP2027Solution) -> CellSet: """ The cells the cutters sweep over, clipped to the region's surroundings. """ - return dilate(self._anchors(solution), self.cutter) + return dilate(self.reachable_anchors(solution), self.cutter) def uncovered_cells(self, solution: CGSHOP2027Solution) -> CellSet: """ @@ -72,11 +72,15 @@ def uncovered_cells(self, solution: CGSHOP2027Solution) -> CellSet: """ return self.region.difference(self.swept_cells(solution)) - def _anchors(self, solution: CGSHOP2027Solution) -> CellSet: + def reachable_anchors(self, solution: CGSHOP2027Solution) -> CellSet: """ Every anchor position visited by any cutter, clipped to the positions from which the cutter can touch the region at all. + The clipped box is what bounds the swept area, so anything drawing + coverage has to agree with it cell for cell or the picture and the + verdict part ways. + Each edge contributes a single slice write, so this costs one operation per edge rather than one per unit of travel. """ diff --git a/src/cgshop2027_pyutils/visualize.py b/src/cgshop2027_pyutils/visualize.py index 7786605..d63c127 100644 --- a/src/cgshop2027_pyutils/visualize.py +++ b/src/cgshop2027_pyutils/visualize.py @@ -23,14 +23,20 @@ from matplotlib import colormaps from matplotlib.animation import FuncAnimation from matplotlib.axes import Axes -from matplotlib.colors import ListedColormap +from matplotlib.colors import ( + Colormap, + LinearSegmentedColormap, + ListedColormap, + Normalize, + to_rgb, +) from matplotlib.figure import Figure from matplotlib.image import AxesImage from matplotlib.patches import PathPatch from matplotlib.patches import Polygon as PolygonPatch from matplotlib.path import Path as MplPath -from .grid import CellSet, _blit, dilate +from .grid import CellCounts, CellSet, _blit_counts, dilate_counts from .schemas import CGSHOP2027Instance, CGSHOP2027Solution, CutterTour from .schemas.instance import PointSequence, PolyominoWithHoles from .verify import SolutionValidator @@ -41,11 +47,24 @@ "create_solution_plot", ] +# Anything matplotlib accepts as a colour; here a hex string or an RGB(A) tuple. +_Color = str | tuple[float, ...] + _REGION_FACE = "#d9d9d9" _REGION_EDGE = "#404040" -_SWEPT = "#7fbf7f" _UNCOVERED = "#d62728" _CUTTER = "#1f77b4" +# The two ends of a cutter's coverage ramp, as distances from its own colour: +# washed out towards white where it passed once, pushed towards black where it +# passed most. The span has to be wide enough that one more pass is a visible +# step, which is what the shading is for. +_SWEPT_LIGHTEN = 0.85 +_SWEPT_DARKEN = 0.6 +# Opaque enough to hold that span, but short of hiding what is underneath: the +# cutters are drawn one over another, so where two of them sweep the same cell +# the one below has to show through the one above. The ramp is deepened to pay +# for what the transparency takes back off the span. +_SWEPT_ALPHA = 0.7 def _ring_vertices(ring: PointSequence) -> list[tuple[int, int]]: @@ -71,8 +90,39 @@ def _polyomino_path(polyomino: PolyominoWithHoles) -> MplPath: return MplPath(vertices, codes) +def _lighten(color: _Color, amount: float) -> tuple[float, ...]: + """ + The colour blended `amount` of the way towards white. + + :param amount: 0 leaves the colour as it is, 1 turns it white. + """ + return tuple(channel + (1.0 - channel) * amount for channel in to_rgb(color)) + + +def _darken(color: _Color, amount: float) -> tuple[float, ...]: + """ + The colour blended `amount` of the way towards black. + + :param amount: 0 leaves the colour as it is, 1 turns it black. + """ + return tuple(channel * (1.0 - amount) for channel in to_rgb(color)) + + +def _shading(color: _Color) -> LinearSegmentedColormap: + """ + The ramp a cutter's coverage is drawn with: washed out where the cutter + passed the fewest times, deepened past its own colour where it passed the + most. It stays recognizably that cutter's colour throughout, since both + ends only move it along its own line to white and to black. + """ + return LinearSegmentedColormap.from_list( + "coverage", + [_lighten(color, _SWEPT_LIGHTEN), _darken(color, _SWEPT_DARKEN)], + ) + + def _draw_cells( - ax: Axes, cells: CellSet, color: str, alpha: float = 1.0, zorder: float = 1.0 + ax: Axes, cells: CellSet, color: _Color, alpha: float = 1.0, zorder: float = 1.0 ) -> AxesImage: """ Draws a cell set as a raster. @@ -95,11 +145,44 @@ def _draw_cells( ) +def _draw_counts( + ax: Axes, + visits: CellCounts, + color: _Color, + busiest: int, + *, + alpha: float = 1.0, + zorder: float = 1.0, +) -> AxesImage: + """ + Draws a count grid as a raster shaded by how often each cell was covered. + + `busiest` sets the dark end of the scale for every cutter alike, so the + same shade means the same number of passes whoever drew it. + """ + x_min, y_min, x_max, y_max = visits.bounds + return ax.imshow( + _as_count_image(visits.counts), + origin="lower", + interpolation="nearest", + extent=(x_min, x_max + 1, y_min, y_max + 1), + cmap=_shading(color), + norm=Normalize(vmin=1, vmax=busiest), + alpha=alpha, + zorder=zorder, + ) + + def _as_image(mask: np.ndarray) -> np.ma.MaskedArray: """Masks out the empty cells so that only the set ones are painted.""" return np.ma.masked_where(~mask, np.ones_like(mask, dtype=np.uint8)) +def _as_count_image(counts: np.ndarray) -> np.ma.MaskedArray: + """Masks out the cells nobody ever covered, leaving the counts to shade.""" + return np.ma.masked_where(counts == 0, counts) + + def _style_axes(ax: Axes, title: str | None = None, *, bare: bool = False) -> None: """ The frame around a drawing: equal aspect always, and everything that is not @@ -141,6 +224,72 @@ def _fit_to_axes(fig: Figure, ax: Axes) -> None: fig.set_size_inches(width * scale, height * scale) +def _draw_region_outline(ax: Axes, instance: CGSHOP2027Instance) -> None: + """The region's boundary over the coverage, so gaps stay readable.""" + ax.add_patch( + PathPatch( + _polyomino_path(instance.region_to_cover), + facecolor="none", + edgecolor=_REGION_EDGE, + linewidth=1.4, + zorder=3, + ) + ) + + +def _draw_tours( + ax: Axes, + instance: CGSHOP2027Instance, + solution: CGSHOP2027Solution, + palette: Colormap, + *, + moving: bool, +) -> list[PolygonPatch]: + """ + Draws each tour in its cutter's colour with the cutter sitting at its + start, and hands the outlines back in tour order. + + :param moving: Style the outlines for an animation, where they are + filled and travel, rather than for a plot, where they + are dashed and stay put. + """ + outline = _cutter_outline(instance) + patches = [] + for index, tour in enumerate(solution.tours): + color = palette(index % palette.N) + ring = _closed(tour) + ax.plot( + [x for x, _ in ring], + [y for _, y in ring], + color=color, + linewidth=1.0 if moving else 1.2, + alpha=0.45 if moving else 1.0, + zorder=2 if moving else 4, + ) + if not moving: + ax.plot( + [tour.start[0]], + [tour.start[1]], + marker="o", + color=color, + markersize=5, + zorder=5, + ) + patch = PolygonPatch( + _placed(outline, tour.start), + closed=True, + facecolor=color if moving else "none", + edgecolor=_REGION_EDGE if moving else color, + linestyle="-" if moving else "--", + alpha=0.75 if moving else 1.0, + linewidth=1.0, + zorder=4 if moving else 5, + ) + ax.add_patch(patch) + patches.append(patch) + return patches + + def _set_limits( ax: Axes, bounds: tuple[float, float, float, float], pad_ratio: float = 0.05 ) -> None: @@ -219,6 +368,91 @@ def _walk(tour: CutterTour, total: int) -> np.ndarray: return positions +def _visit_counts( + validator: SolutionValidator, solution: CGSHOP2027Solution +) -> list[CellCounts]: + """ + How often each cutter covers each cell, one count grid per tour. + + A cutter that comes back over a cell later in its tour counts again, so + the grid says how much of the effort went where rather than merely where + it went. + + Every grid is clipped to the same neighbourhood of the region that + `SolutionValidator.swept_cells` clips their union to, so `nonzero()` is + exactly that cutter's share of the swept area and the picture cannot + drift from the verdict. + """ + box = validator.reachable_anchors(solution) + return [ + dilate_counts(_anchor_visits(tour, box), validator.cutter) + for tour in solution.tours + ] + + +def _anchor_visits(tour: CutterTour, box: CellSet) -> CellCounts: + """ + How often the cutter's anchor sits on each lattice point of `box` over + one lap of the tour. + + Each edge brings its start but not its end, so the point two consecutive + edges share is counted once and the lap adds up to the tour's length. A + tour with no edges is a cutter standing still, and counts once where it + stands. + """ + counts = np.zeros(box.mask.shape, dtype=np.int64) + edges = list(tour.edges()) + if not edges: + _count_run(counts, box.origin, tour.start, tour.start) + for start, end in edges: + step_x = (end[0] > start[0]) - (end[0] < start[0]) + step_y = (end[1] > start[1]) - (end[1] < start[1]) + _count_run(counts, box.origin, start, (end[0] - step_x, end[1] - step_y)) + return CellCounts(counts, box.origin) + + +def _count_run( + counts: np.ndarray, + origin: tuple[int, int], + start: tuple[int, int], + end: tuple[int, int], +) -> None: + """ + Adds one to every lattice point of an axis-parallel run, both ends + included, clipping whatever falls outside the grid. + """ + (x0, y0), (x1, y1) = start, end + origin_x, origin_y = origin + height, width = counts.shape + if y0 == y1: + row = y0 - origin_y + if not 0 <= row < height: + return + low, high = sorted((x0, x1)) + low, high = max(low - origin_x, 0), min(high - origin_x, width - 1) + if low <= high: + counts[row, low : high + 1] += 1 + return + column = x0 - origin_x + if not 0 <= column < width: + return + low, high = sorted((y0, y1)) + low, high = max(low - origin_y, 0), min(high - origin_y, height - 1) + if low <= high: + counts[low : high + 1, column] += 1 + + +def _scale_max(visits: list[CellCounts]) -> int: + """ + The count the dark end of the shading stands for. + + Two is the floor: with no cell passed over twice there is nothing to + grade, and a cell passed over once then keeps the shade it has in every + other picture. + """ + return max(max((grid.maximum for grid in visits), default=1), 2) + + def create_instance_plot(instance: CGSHOP2027Instance, *, bare: bool = False) -> Figure: """ Plots the region to cover next to the cutter shape. @@ -288,6 +522,11 @@ def create_solution_plot( Plots a solution: the area the cutters sweep, the gaps they leave, and the tour of each cutter. + Each cutter fills the area it sweeps in its own colour, shaded by how + often it passed over each cell: nearly white where it went by once, the + full colour over the cell it worked hardest. The scale is shared by all + cutters, and where two of them sweep the same cell their fills overlap. + :param bare: Draw the region, the coverage and the tours alone, without axes, grid or titles -- the verdict included, so a gap then shows only as the red it is drawn in. @@ -295,55 +534,27 @@ def create_solution_plot( validator = SolutionValidator(instance) errors = validator.check_for_errors(solution) swept = validator.swept_cells(solution) + visits = _visit_counts(validator, solution) + busiest = _scale_max(visits) uncovered = validator.uncovered_cells(solution) + palette = colormaps["tab10"] fig, ax = plt.subplots(figsize=(9, 9)) # The swept area is not clipped to the region, so effort spent just outside # it is visible too. It is clipped to the region's neighbourhood by the # verifier though, so a longer excursion shows only as a trajectory. - _draw_cells(ax, swept, _SWEPT, alpha=0.55, zorder=1) - _draw_cells(ax, uncovered, _UNCOVERED, alpha=0.85, zorder=2) - ax.add_patch( - PathPatch( - _polyomino_path(instance.region_to_cover), - facecolor="none", - edgecolor=_REGION_EDGE, - linewidth=1.4, - zorder=3, - ) - ) - - palette = colormaps["tab10"] - outline = _cutter_outline(instance) - for index, tour in enumerate(solution.tours): - color = palette(index % palette.N) - ring = _closed(tour) - ax.plot( - [x for x, _ in ring], - [y for _, y in ring], - color=color, - linewidth=1.2, - zorder=4, - ) - ax.plot( - [tour.start[0]], - [tour.start[1]], - marker="o", - color=color, - markersize=5, - zorder=5, - ) - ax.add_patch( - PolygonPatch( - _placed(outline, tour.start), - closed=True, - facecolor="none", - edgecolor=color, - linestyle="--", - linewidth=1.0, - zorder=5, - ) + for index, counted in enumerate(visits): + _draw_counts( + ax, + counted, + palette(index % palette.N), + busiest, + alpha=_SWEPT_ALPHA, + zorder=1, ) + _draw_cells(ax, uncovered, _UNCOVERED, alpha=0.85, zorder=2) + _draw_region_outline(ax, instance) + _draw_tours(ax, instance, solution, palette, moving=False) _set_limits( ax, @@ -386,6 +597,10 @@ def create_solution_animation( Animates the cutters moving along their tours, filling in the area they have swept so far. + Each cutter fills in the area it has swept in its own colour, deepening a + cell every time it comes back over it, so both who covered what and how + hard they worked at it stay readable as the coverage grows. + All cutters travel at the same speed, one unit per step, so the animation runs for as many steps as the longest tour is long; a cutter that is already back where it started simply stays there. @@ -408,48 +623,38 @@ def create_solution_animation( shown.append(total) walks = [_walk(tour, total) for tour in solution.tours] + # A cutter that is back at its start is standing still, not passing over + # its spot again and again, so each tour counts its own steps only and + # never the padding `_walk` adds. The last frame then leaves exactly the + # counts `create_solution_plot` draws. + steps = [max(tour.length, 1) for tour in solution.tours] swept = validator.swept_cells(solution) - covered = np.zeros_like(swept.mask) + # The positions the verifier keeps: from anywhere else the cutter cannot + # touch the region, and the plot's counts leave those placements out too. + reachable = validator.reachable_anchors(solution) + palette = colormaps["tab10"] + # Fixed up front from the finished counts, so that a cell does not change + # shade as the scale it is measured against grows under it. + busiest = _scale_max(_visit_counts(validator, solution)) + # The layers share the union's origin and extent, so they line up both with + # each other and with the blits that fill them in. + covered = [np.zeros(swept.mask.shape, dtype=np.int64) for _ in solution.tours] fig, ax = plt.subplots(figsize=(9, 9)) - image = _draw_cells( - ax, CellSet(covered, swept.origin), _SWEPT, alpha=0.55, zorder=1 - ) - ax.add_patch( - PathPatch( - _polyomino_path(instance.region_to_cover), - facecolor="none", - edgecolor=_REGION_EDGE, - linewidth=1.4, - zorder=3, + images = [ + _draw_counts( + ax, + CellCounts(layer, swept.origin), + palette(index % palette.N), + busiest, + alpha=_SWEPT_ALPHA, + zorder=1, ) - ) - - palette = colormaps["tab10"] + for index, layer in enumerate(covered) + ] + _draw_region_outline(ax, instance) + patches = _draw_tours(ax, instance, solution, palette, moving=True) outline = _cutter_outline(instance) - patches = [] - for index, tour in enumerate(solution.tours): - color = palette(index % palette.N) - ring = _closed(tour) - ax.plot( - [x for x, _ in ring], - [y for _, y in ring], - color=color, - linewidth=1.0, - alpha=0.45, - zorder=2, - ) - patch = PolygonPatch( - _placed(outline, tour.start), - closed=True, - facecolor=color, - edgecolor=_REGION_EDGE, - alpha=0.75, - linewidth=1.0, - zorder=4, - ) - ax.add_patch(patch) - patches.append(patch) _set_limits( ax, @@ -465,40 +670,48 @@ def create_solution_animation( stamped_until = -1 def stamp(first: int, last: int) -> None: - """Adds every placement in `first .. last` to the covered area.""" - for positions in walks: - anchors = _anchor_cells(positions[first : last + 1]) - if anchors is None: + """Adds every placement in `first .. last` to each cutter's counts.""" + for positions, layer, limit in zip(walks, covered, steps, strict=True): + walked = positions[first : min(last + 1, limit)] + if len(walked): + walked = walked[reachable.contains_points(walked)] + visited = _visited_anchors(walked) + if visited is None: continue - painted = dilate(anchors, validator.cutter) - _blit(covered, swept.origin, painted.mask, painted.origin) + painted = dilate_counts(visited, validator.cutter) + _blit_counts(layer, swept.origin, painted.counts, painted.origin) def update(frame: int): nonlocal stamped_until travelled = shown[frame] if travelled < stamped_until: # the animation looped back to the start - covered[:] = False + for layer in covered: + layer[:] = 0 stamped_until = -1 # Every intermediate placement is stamped, so a large stride never # makes the coverage lag behind the cutters. stamp(stamped_until + 1, travelled) stamped_until = travelled - image.set_data(_as_image(covered)) + for image, layer in zip(images, covered, strict=True): + image.set_data(_as_count_image(layer)) for patch, positions in zip(patches, walks, strict=True): patch.set_xy(_placed(outline, positions[travelled])) if not bare: ax.set_title(f"step {travelled} / {total}", fontsize=9, pad=4) - return [image, *patches] + return [*images, *patches] return FuncAnimation(fig, update, frames=len(shown), interval=interval, blit=False) -def _anchor_cells(positions: np.ndarray) -> CellSet | None: - """The given anchor positions as a cell set over their own bounding box.""" +def _visited_anchors(positions: np.ndarray) -> CellCounts | None: + """ + The given anchor positions as a count grid over their own bounding box, + a position stood on twice counting twice. + """ if not len(positions): return None x_min, y_min = (int(v) for v in positions.min(axis=0)) x_max, y_max = (int(v) for v in positions.max(axis=0)) - mask = np.zeros((y_max - y_min + 1, x_max - x_min + 1), dtype=bool) - mask[positions[:, 1] - y_min, positions[:, 0] - x_min] = True - return CellSet(mask, (x_min, y_min)) + counts = np.zeros((y_max - y_min + 1, x_max - x_min + 1), dtype=np.int64) + np.add.at(counts, (positions[:, 1] - y_min, positions[:, 0] - x_min), 1) + return CellCounts(counts, (x_min, y_min)) diff --git a/tests/test_grid.py b/tests/test_grid.py index 4846932..11d0c32 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -2,7 +2,14 @@ import pytest from shapely.geometry import Polygon -from cgshop2027_pyutils.grid import CellSet, dilate, rasterize, rasterize_ring +from cgshop2027_pyutils.grid import ( + CellCounts, + CellSet, + dilate, + dilate_counts, + rasterize, + rasterize_ring, +) from cgshop2027_pyutils.schemas.instance import PointSequence, PolyominoWithHoles SQUARE = {"x": [0, 5, 5, 0], "y": [0, 0, 5, 5]} @@ -148,3 +155,81 @@ def test_dilate_matches_the_naive_sum(): assert set(dilate(cells, structuring).cells()) == naive_dilate( cells, structuring ) + + +# -------------------------------------------------------------------------- +# CellCounts and dilate_counts +# -------------------------------------------------------------------------- + + +def counted(cells: CellSet) -> CellCounts: + """The cell set as a count grid, every cell counted once.""" + return CellCounts(cells.mask.astype(np.int64), cells.origin) + + +def naive_counts(cells: CellSet, structuring: CellSet) -> dict[tuple[int, int], int]: + """Every placement stamped one at a time, counting how often each lands.""" + tally: dict[tuple[int, int], int] = {} + for cx, cy in cells.cells(): + for sx, sy in structuring.cells(): + tally[(cx + sx, cy + sy)] = tally.get((cx + sx, cy + sy), 0) + 1 + return tally + + +def as_dict(counts: CellCounts) -> dict[tuple[int, int], int]: + rows, cols = np.nonzero(counts.counts) + return { + (counts.origin[0] + col, counts.origin[1] + row): int(counts.counts[row, col]) + for row, col in zip(rows.tolist(), cols.tolist(), strict=True) + } + + +def test_cell_counts_reports_its_box_like_a_cell_set(): + counts = CellCounts(np.array([[0, 2], [1, 0]]), (4, 7)) + assert counts.bounds == (4, 7, 5, 8) + assert (counts.width, counts.height) == (2, 2) + assert counts.maximum == 2 + + +def test_cell_counts_reads_back_as_the_set_it_counts(): + counts = CellCounts(np.array([[0, 2], [1, 0]]), (4, 7)) + assert set(counts.nonzero().cells()) == {(5, 7), (4, 8)} + + +def test_an_empty_grid_has_no_maximum(): + assert CellCounts(np.zeros((0, 0), dtype=np.int64), (0, 0)).maximum == 0 + + +@pytest.mark.parametrize("structuring", [SQUARE, L_SHAPE, STAIRCASE]) +def test_dilate_counts_matches_stamping_one_placement_at_a_time(structuring): + cells = rasterize_ring(ring(STAIRCASE)) + shape = rasterize_ring(ring(structuring)) + assert as_dict(dilate_counts(counted(cells), shape)) == naive_counts(cells, shape) + + +@pytest.mark.parametrize("structuring", [SQUARE, L_SHAPE, STAIRCASE]) +def test_dilate_counts_covers_exactly_what_dilate_covers(structuring): + """ + The counting version must not reach a cell the boolean one misses, nor + miss one it reaches; only the multiplicity is new. + """ + cells = rasterize_ring(ring(STAIRCASE)) + shape = rasterize_ring(ring(structuring)) + counts = dilate_counts(counted(cells), shape) + assert set(counts.nonzero().cells()) == set(dilate(cells, shape).cells()) + + +def test_dilate_counts_carries_multiplicities_through(): + """A cell that starts at 3 contributes 3 to everything it reaches.""" + cells = CellSet(np.array([[True]]), (0, 0)) + shape = rasterize_ring(ring(L_SHAPE)) + once = dilate_counts(counted(cells), shape) + thrice = dilate_counts(CellCounts(np.array([[3]]), (0, 0)), shape) + assert as_dict(thrice) == {cell: 3 * n for cell, n in as_dict(once).items()} + + +def test_dilate_counts_by_a_single_cell_is_a_translation(): + cells = rasterize_ring(ring(STAIRCASE)) + shifted = dilate_counts(counted(cells), CellSet(np.array([[True]]), (2, -3))) + assert set(shifted.nonzero().cells()) == set(cells.translated(2, -3).cells()) + assert shifted.maximum == 1 diff --git a/tests/test_visualize.py b/tests/test_visualize.py index b576648..708fa56 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -4,6 +4,7 @@ import numpy as np import pytest from conftest import footprint_polygon +from matplotlib import colormaps from matplotlib.animation import FuncAnimation from matplotlib.figure import Figure from matplotlib.patches import Polygon as PolygonPatch @@ -13,12 +14,16 @@ from cgshop2027_pyutils.schemas import CGSHOP2027Instance, CGSHOP2027Solution from cgshop2027_pyutils.verify import SolutionValidator from cgshop2027_pyutils.visualize import ( + _visit_counts, create_instance_plot, create_solution_animation, create_solution_plot, ) UNIT_CUTTER = {"x": [0, 1, 1, 0], "y": [0, 0, 1, 1]} +# Wide enough that neighbouring placements overlap, so a plain tour already +# passes over a cell more than once. +BLOCK_CUTTER = {"x": [0, 2, 2, 0], "y": [0, 0, 2, 2]} # An L, so that no symmetry can hide a misplaced anchor; every center below is # a vertex of it, which the schema accepts because the cutter is closed. L_CUTTER = {"x": [0, 2, 2, 1, 1, 0], "y": [0, 0, 1, 1, 2, 2]} @@ -159,6 +164,246 @@ def test_solution_plot_includes_area_swept_outside_the_region(): assert x_min <= -3 +# -------------------------------------------------------------------------- +# coverage is coloured per cutter and shaded by how often it passed +# -------------------------------------------------------------------------- + + +def coverage_rasters(axes) -> list: + """The coverage layers of a plot, in the order they were drawn.""" + return list(axes.images) + + +def cutter_colors(count: int) -> list[tuple[float, float, float]]: + """The colour the plots give each cutter, in tour order.""" + palette = colormaps["tab10"] + return [tuple(palette(index % palette.N)[:3]) for index in range(count)] + + +def shade(image, count: int) -> tuple[float, ...]: + """The colour a raster paints a cell that was covered `count` times.""" + return tuple(image.get_cmap()(image.norm(count))[:3]) + + +def luminance(color) -> float: + return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2] + + +TWO_CUTTERS = (boustrophedon(rows=2), boustrophedon(y0=2, rows=2)) + + +def two_cutter_plot(): + return create_solution_plot(make_instance(cutters=2), make_solution(*TWO_CUTTERS)) + + +def test_each_cutter_gets_its_own_coverage_layer(): + axes = two_cutter_plot().axes[0] + # One coverage layer per cutter, plus the uncovered-cell layer. + assert len(coverage_rasters(axes)) == 3 + + +def test_a_cutter_shades_its_coverage_in_its_own_colour(): + """ + The fill has to name the cutter that swept it: washed out towards white + where it passed once, deepened towards black where it passed most, but the + same hue as the tour drawn over it throughout. + """ + axes = two_cutter_plot().axes[0] + for raster, own in zip(coverage_rasters(axes)[:2], cutter_colors(2), strict=True): + light, dark = shade(raster, 1), shade(raster, raster.norm.vmax) + assert luminance(light) > luminance(own) > luminance(dark) + # Both ends sit on the line through the cutter's colour, so the hue -- + # which channel leads -- never changes. + assert np.argmax(light) == np.argmax(own) == np.argmax(dark) + + +def test_more_passes_are_drawn_darker(): + (raster, *_) = coverage_rasters(two_cutter_plot().axes[0]) + shades = [luminance(shade(raster, n)) for n in range(1, int(raster.norm.vmax) + 1)] + assert shades == sorted(shades, reverse=True) + + +def test_two_cutters_are_told_apart_by_colour(): + first, second = coverage_rasters(two_cutter_plot().axes[0])[:2] + assert shade(first, 1) != shade(second, 1) + + +def test_every_cutter_is_measured_against_the_same_scale(): + """ + A shade has to mean the same number of passes whichever cutter drew it, + which it only does if they share one scale rather than each stretching + its own coverage over the whole ramp. + """ + instance = make_instance(size=6, cutter=BLOCK_CUTTER, cutters=2) + solution = make_solution( + boustrophedon(rows=3), + tour((0, 2), (4, 2), (4, 4), (2, 4), (2, 0), (0, 0)), + ) + grids = _visit_counts(SolutionValidator(instance), solution) + # Without this the test would hold for a per-cutter scale just as well. + assert grids[0].maximum != grids[1].maximum + rasters = coverage_rasters(create_solution_plot(instance, solution).axes[0]) + assert len({(r.norm.vmin, r.norm.vmax) for r in rasters[:2]}) == 1 + + +def test_a_cell_swept_once_is_shaded_the_same_whatever_the_busiest_cell(): + """ + The light end stands for a single pass, not for `1 / busiest`, so a picture + of a tidy solution is not washed out by a wasteful one. + """ + tidy = create_solution_plot(make_instance(), make_solution()) + # A tour that keeps going back over the same row raises the busiest count. + wasteful = create_solution_plot( + make_instance(), make_solution(boustrophedon(rows=4, width=1)) + ) + (a, *_), (b, *_) = (coverage_rasters(figure.axes[0]) for figure in (tidy, wasteful)) + assert shade(a, 1) == pytest.approx(shade(b, 1)) + + +def test_the_scale_spans_at_least_two_passes(): + """With nothing covered twice there is no range to grade, and no crash.""" + (raster, *_) = coverage_rasters( + create_solution_plot( + make_instance(size=1), make_solution(tour((0, 0), (1, 0))) + ).axes[0] + ) + assert raster.norm.vmax >= 2 + + +# -------------------------------------------------------------------------- +# how often each cutter passed over a cell +# -------------------------------------------------------------------------- + + +def test_a_cutter_that_comes_back_counts_again(): + """ + A tour that crosses itself passes over the crossing twice, and the cell + there has to say 2 while the rest of the tour says 1. + """ + instance = make_instance(size=5) + # A bowtie whose two strokes both run through (2, 2). + crossing = make_solution(tour((0, 2), (4, 2), (4, 4), (2, 4), (2, 0), (0, 0))) + (grid,) = _visit_counts(SolutionValidator(instance), crossing) + assert grid.maximum == 2 + at_crossing = grid.counts[2 - grid.origin[1], 2 - grid.origin[0]] + assert at_crossing == 2 + elsewhere = grid.counts[2 - grid.origin[1], 1 - grid.origin[0]] + assert elsewhere == 1 + + +def test_the_lap_counts_its_start_once(): + """ + A tour closes onto its own start, and the cutter is there once, not twice. + Every cell is then reached by exactly `length` placements of the cutter. + """ + instance = make_instance(size=6) + solution = make_solution(tour((0, 0), (4, 0), (4, 4), (0, 4))) + (grid,) = _visit_counts(SolutionValidator(instance), solution) + expected = solution.tours[0].length * len(SolutionValidator(instance).cutter) + assert int(grid.counts.sum()) == expected + + +def test_a_stationary_cutter_counts_once(): + instance = make_instance() + (grid,) = _visit_counts(SolutionValidator(instance), make_solution(tour((2, 2)))) + assert grid.maximum == 1 + assert int(grid.counts.sum()) == len(SolutionValidator(instance).cutter) + + +def test_counts_cover_exactly_what_the_verifier_calls_swept(): + """ + Splitting the coverage per cutter and counting it must neither lose nor + invent a cell, or the picture would contradict the verdict above it. + """ + instance = make_instance(cutters=2) + solution = make_solution(*TWO_CUTTERS) + validator = SolutionValidator(instance) + grids = _visit_counts(validator, solution) + for index, grid in enumerate(grids): + alone = solution.model_copy(update={"tours": [solution.tours[index]]}) + assert set(grid.nonzero().cells()) == set(validator.swept_cells(alone).cells()) + union = set().union(*(set(grid.nonzero().cells()) for grid in grids)) + assert union == set(validator.swept_cells(solution).cells()) + + +def test_placements_outside_the_regions_reach_are_left_out(): + """ + The verifier clips the anchors to where the cutter can still touch the + region; the counts have to drop the same placements, or the drawn area + would run past the one the verdict is based on. + """ + instance = make_instance(size=4) + detour = make_solution(tour((0, 0), (-30, 0), (-30, 1), (0, 1))) + validator = SolutionValidator(instance) + (grid,) = _visit_counts(validator, detour) + assert set(grid.nonzero().cells()) == set(validator.swept_cells(detour).cells()) + + +# -------------------------------------------------------------------------- +# the animation counts the same way the plot does +# -------------------------------------------------------------------------- + + +def test_the_animation_paints_one_raster_per_cutter(): + animation = create_solution_animation( + make_instance(cutters=2), make_solution(*TWO_CUTTERS) + ) + rasters = coverage_rasters(animation._fig.axes[0]) + assert len(rasters) == 2 + assert shade(rasters[0], 1) != shade(rasters[1], 1) + + +@pytest.mark.parametrize("max_frames", [300, 5, 2]) +@pytest.mark.parametrize("wanders", [False, True]) +def test_the_animation_ends_on_exactly_the_counts_the_plot_draws( + max_frames: int, *, wanders: bool +): + """ + Whatever the stride, the last frame has to leave each cutter's counts as + `create_solution_plot` would draw them -- padding for a cutter that finished + early included, since standing still is not another pass, and a cutter that + wanders out of the region's reach, whose placements out there the plot drops. + """ + # The cutter has to be wider than a cell: a single-cell one placed out of + # the region's reach paints only out there, and the clipping would not show. + instance = make_instance(size=6, cutter=BLOCK_CUTTER, cutters=2) + # One short tour and one long one, so a cutter is parked while the other runs. + short = ( + tour((0, 0), (-30, 0), (-30, 1), (0, 1)) if wanders else tour((0, 0), (1, 0)) + ) + solution = make_solution(short, boustrophedon(rows=4)) + animation = create_solution_animation(instance, solution, max_frames=max_frames) + for frame in frames_of(animation): + artists = animation._func(frame) + rasters = rasters_of(animation, artists) + swept = SolutionValidator(instance).swept_cells(solution) + for raster, grid in zip( + rasters, _visit_counts(SolutionValidator(instance), solution), strict=True + ): + expected = np.zeros_like(passes(raster)) + dx = grid.origin[0] - swept.origin[0] + dy = grid.origin[1] - swept.origin[1] + expected[dy : dy + grid.counts.shape[0], dx : dx + grid.counts.shape[1]] = ( + grid.counts + ) + assert np.array_equal(passes(raster), expected) + + +def test_the_animated_scale_does_not_shift_as_coverage_grows(): + """ + The scale is fixed from the finished counts, so a cell keeps its shade + instead of being repainted every time some other cell overtakes it. + """ + animation = create_solution_animation( + make_instance(cutters=2), make_solution(*TWO_CUTTERS) + ) + rasters = coverage_rasters(animation._fig.axes[0]) + before = [(r.norm.vmin, r.norm.vmax) for r in rasters] + for frame in frames_of(animation): + animation._func(frame) + assert [(r.norm.vmin, r.norm.vmax) for r in rasters] == before + + # -------------------------------------------------------------------------- # the anchor is the cutter's center # -------------------------------------------------------------------------- @@ -213,6 +458,22 @@ def covered_cells(image) -> int: return int((~np.ma.getmaskarray(image.get_array())).sum()) +def painted(images) -> int: + """The cells some cutter has painted, counting a shared cell once.""" + masks = [~np.ma.getmaskarray(image.get_array()) for image in images] + return int(np.logical_or.reduce(masks).sum()) + + +def passes(image) -> np.ndarray: + """The pass count behind each cell of a coverage raster, 0 where unpainted.""" + return np.ma.filled(image.get_array(), 0).astype(np.int64) + + +def rasters_of(animation, artists) -> list: + """The coverage rasters among the artists a frame returned.""" + return list(artists[: len(animation._fig.axes[0].images)]) + + def test_animation_shows_every_step_of_a_short_tour(): solution = make_solution() animation = create_solution_animation(make_instance(), solution) @@ -258,7 +519,7 @@ def test_a_cutter_with_a_short_tour_waits_at_its_start(): animation = create_solution_animation(make_instance(cutters=2), solution) instance = make_instance(cutters=2) for frame in frames_of(animation): - (_, _, patch) = animation._func(frame) + (*_, patch) = animation._func(frame) assert misplaced(patch, instance, (6, 6)) == pytest.approx(0.0) @@ -274,8 +535,8 @@ def test_coverage_grows_monotonically_to_the_full_swept_area(max_frames: int): animation = create_solution_animation(instance, solution, max_frames=max_frames) covered = [] for frame in frames_of(animation): - (image, *_) = animation._func(frame) - covered.append(covered_cells(image)) + artists = animation._func(frame) + covered.append(painted(rasters_of(animation, artists))) assert covered == sorted(covered) assert covered[-1] == expected assert covered[0] < covered[-1] @@ -296,8 +557,8 @@ def test_the_animated_cutter_and_its_trail_are_anchored_at_the_center(center): assert misplaced(patch, instance, solution.tours[0].start) == pytest.approx(0.0) for frame in frames_of(animation): - (image, *_) = animation._func(frame) - assert covered_cells(image) == len( + artists = animation._func(frame) + assert painted(rasters_of(animation, artists)) == len( SolutionValidator(instance).swept_cells(solution) ) @@ -309,8 +570,8 @@ def test_animation_restarts_cleanly_when_it_loops(): frames = frames_of(animation) for frame in frames: animation._func(frame) - (image, *_) = animation._func(frames[0]) - first_again = covered_cells(image) + artists = animation._func(frames[0]) + first_again = painted(rasters_of(animation, artists)) assert first_again == len(SolutionValidator(instance).cutter)