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)