From 3818a9908ca5f61638512219897fba0128c90118 Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Tue, 25 Aug 2026 07:30:41 -0700 Subject: [PATCH 1/7] feat(inference): an outline follows the mask's edges to a pixel tolerance The mask is traced along its pixels' edges, smoothed once with corner cutting over those unit edges, and reduced at a quarter pixel to form the canonical contour. The polygon is then simplified at an absolute tolerance in asset pixels: every point of the contour lies within it of the polygon. The three-step detail vocabulary and its diagonal-relative tolerance are removed from the domain. --- src/visionset/inference/__init__.py | 4 - src/visionset/inference/masks.py | 270 +++++++++++---------- src/visionset/kernel/domain/__init__.py | 10 +- src/visionset/kernel/domain/suggestion.py | 80 +++--- tests/inference/test_masks.py | 209 +++++++++++----- tests/kernel/test_suggestion_parameters.py | 24 +- 6 files changed, 349 insertions(+), 248 deletions(-) diff --git a/src/visionset/inference/__init__.py b/src/visionset/inference/__init__.py index b4f0220a..1859d928 100644 --- a/src/visionset/inference/__init__.py +++ b/src/visionset/inference/__init__.py @@ -70,7 +70,6 @@ purge, ) from visionset.inference.masks import ( - EPSILON, MINIMUM_FRAGMENT_SHARE, MINIMUM_TOLERANCE, Piece, @@ -81,7 +80,6 @@ polygon_at, shapes_from, simplified, - tolerance_for, ) from visionset.inference.nms import DEFAULT_IOU_THRESHOLD, suppressed from visionset.inference.prelabel import ( @@ -164,7 +162,6 @@ "registered", "reset", "serving", - "EPSILON", "MPS_FALLBACK_VARIABLE", "enable_mps_fallback", "DEFAULT_EMBEDDING_CAPACITY", @@ -223,7 +220,6 @@ "shapes_from", "shapes_prose", "simplified", - "tolerance_for", "planned", "pre_label", "provider_for", diff --git a/src/visionset/inference/masks.py b/src/visionset/inference/masks.py index 1e096cda..e23cbf9d 100644 --- a/src/visionset/inference/masks.py +++ b/src/visionset/inference/masks.py @@ -19,18 +19,19 @@ 1. :func:`components` — which pieces of the mask survive the noise filter. 2. :func:`filled` — the gaps in them narrower than a reach, closed. -3. :func:`contour` — the boundary of what is left. -4. :func:`polygon_at` — that boundary, reduced to a vertex count somebody can edit. +3. :func:`contour` — the boundary of what is left: traced along the pixels' edges, + smoothed once, and reduced at :data:`MINIMUM_TOLERANCE`. +4. :func:`polygon_at` — that boundary, within a pixel tolerance somebody chose. The geometry branch happens after step 2: a polygon class takes steps 3 and 4 on the piece the prompt points at, a box class takes one extent over *every* -surviving piece. **A box therefore does not depend on ``detail``**, which is what -"applies to polygon only" means once it is code rather than a table. +surviving piece. **A box therefore does not depend on the tolerance**, which is +what "applies to polygon only" means once it is code rather than a table. **Only one of these is a question anybody is asked.** The reach of the close and the noise floor are fixed here, because on the ordinary single clean piece every setting of either produced the same shape — controls wired to nothing (#557). -``detail`` is the one that moves something a person can see. +The tolerance is the one that moves something a person can see. **Which shape is produced is the caller's schema decision, not this module's guess.** :func:`shapes_from` takes the geometry kinds the active class actually @@ -39,35 +40,31 @@ class allowing only boxes gets extents, and a class allowing neither is not offered the gesture at all. Nothing is ever widened — a box cannot become the outline it never held. -**Tolerance is relative, and that is what makes one "detail" setting work.** The -design asks for a knob that lands typical objects in a 10-40 vertex range. An -absolute pixel tolerance cannot: three pixels is nothing on a car and is the -whole of a bottle cap. So the tolerance handed to Douglas-Peucker is a fraction -of the region's own bounding diagonal, which makes the vertex count a property of -the *shape* rather than of how much of the frame it happens to fill. +**The tolerance is a distance in the asset's pixels, and that is the whole +contract.** Every point of the contour lies within ``tolerance`` of the polygon +that is finally written, so the number means the same thing on every object and a +person reading it knows what they will get before they move it. **The canonical contour, and why step 3 reduces before step 4 gets a choice.** -Douglas-Peucker is not nested: reducing at half a pixel and then at five pixels -does not give what reducing once at five pixels gives. The editor re-simplifies -locally so that moving ``detail`` costs no round trip, while this module stays +Douglas-Peucker is not nested: reducing at a quarter pixel and then at five does +not give what reducing once at five gives. The editor re-simplifies locally so +that moving the tolerance costs no round trip, while this module stays authoritative on what is finally written — and those two can only be proved to agree if they start from the same points. So :func:`contour` is *defined* as the -traced boundary reduced once at :data:`MINIMUM_TOLERANCE`, that is what travels -to a client, and :func:`polygon_at` takes it rather than a raw trace. It also -bounds a payload that would otherwise run to tens of thousands of integer-pixel -points on a large object. +smoothed trace reduced once at :data:`MINIMUM_TOLERANCE`, that is what travels to +a client, and :func:`polygon_at` takes it rather than a raw trace. """ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from typing import Final from visionset.kernel.domain import ( - DEFAULT_DETAIL, + DEFAULT_TOLERANCE, + MINIMUM_TOLERANCE, BboxGeometry, - Detail, Geometry, GeometryType, Mask, @@ -75,33 +72,7 @@ class allowing only boxes gets extents, and a class allowing neither is not ) Point = tuple[float, float] - -EPSILON: Final[Mapping[Detail, float]] = { - Detail.COARSE: 0.025, - Detail.BALANCED: 0.01, - Detail.FINE: 0.004, -} -"""What each step means, as a fraction of the region's bounding diagonal. - -``BALANCED`` is calibrated rather than chosen by taste, and the other two are -placed around it. For a roughly circular object it keeps the vertices where the -sagitta of a chord exceeds the tolerance, which works out at ~13 — inside the -10-40 band with room on both sides for shapes more and less convoluted than a -circle. ``COARSE`` is two and a half times as tolerant and ``FINE`` two and a -half times as strict, which moves the same circle to roughly 8 and roughly 21: -three settings a person can tell apart without any of them being useless. - -It is a mapping here and not a member value on ``Detail`` because the numbers are -a property of *this* simplification algorithm. A second one would want its own -table and the same three names. -""" - -MINIMUM_TOLERANCE: Final = 0.5 -"""No tolerance below half a pixel, however small the region. - -Below this the simplification is arguing about detail the mask does not have — -its own coordinates are integers — and the vertex count runs away for nothing. -""" +Corner = tuple[int, int] MINIMUM_FRAGMENT_SHARE: Final = 0.05 """How big a piece has to be, against the biggest one, to survive the noise filter. @@ -129,7 +100,8 @@ class allowing only boxes gets extents, and a class allowing neither is not feature of the shape. Fixed rather than asked for (#557). It lives here rather than in the domain -because it is a number about *this* pipeline, the way :data:`EPSILON` is. +because it is a number about *this* pipeline, the way +:data:`MINIMUM_FRAGMENT_SHARE` is. """ MAXIMUM_CLOSING_RADIUS: Final = 6 @@ -518,58 +490,111 @@ def filled(mask: Mask) -> Mask: ] +def _set_bits(value: int) -> Iterator[int]: + while value: + low = value & -value + yield low.bit_length() - 1 + value ^= low + + +def _turned(options: list[Corner], *, at: Corner, heading: tuple[int, int]) -> Corner: + """Which way out of a corner that has more than one, left turn first. + + A corner with two ways out is where two lit pixels touch only diagonally. The + left turn crosses onto the other pixel and keeps the 8-connected piece one + ring; the right turn would close round the first pixel alone and cut the piece + in two, which is not what :func:`components` said the piece was. + """ + left = (heading[1], -heading[0]) + right = (-heading[1], heading[0]) + for wanted in (left, heading, right): + for index, candidate in enumerate(options): + if (candidate[0] - at[0], candidate[1] - at[1]) == wanted: + return options.pop(index) + raise AssertionError("a corner's ways out are its own edges") + + def outline(mask: Mask) -> list[Point]: - """The boundary of the piece this mask holds. - - Moore-neighbourhood tracing with Jacob's stopping criterion: walk the ring of - lit pixels, at each one resuming the search from where the previous step - arrived, and stop on re-entering the start pixel from the direction first - used to leave it. Stopping merely on *reaching* the start again is the - classic bug — a shape with a one-pixel isthmus revisits its start mid-trace - and the outline comes back truncated. - - The walk cannot leave the piece it starts in: it only ever steps to an - 8-adjacent lit pixel, and two pixels 8-adjacent to each other are the same - piece by definition. Which piece it starts in is no longer a question here — - :func:`components` has already made the mask hold exactly one. + """The boundary of the piece this mask holds, along the pixels' edges. + + Vertices sit at pixel corners, so a lone lit pixel comes back as its unit + square and the ring describes where the mask ends rather than a path through + its outermost pixels. Clockwise, starting at the top-left corner of the + topmost-leftmost lit pixel — a corner that has exactly one way in and one + way out, which is what lets the walk stop on reaching it again. + + The edges come off the row bitsets: a pixel's top edge is on the ring where + the row above is unlit at that column, and so on for the other three sides. + So only boundary pixels are ever visited, and the walk is linear in the + perimeter rather than in the area. + + The walk cannot leave the piece it starts in: every edge belongs to a lit + pixel and two pixels sharing a corner are the same 8-connected piece — which + is the piece :func:`components` already made the mask hold exactly one of. + Holes have rings of their own and the walk never reaches them. """ found = runs(mask) if not found: return [] - start = (found[0][1], found[0][0]) - height, width = len(mask), len(mask[0]) - - def lit(point: tuple[int, int]) -> bool: - x, y = point - return 0 <= x < width and 0 <= y < height and bool(mask[y][x]) + rows, _ = _bits(mask, pad=0) + height = len(rows) + edges: dict[Corner, list[Corner]] = {} - # Clockwise from due west, which is where a scan arriving from the left came - # from — so the first candidate examined is the one just above the start. - around: Final = ((-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1)) + def edge(start: Corner, end: Corner) -> None: + edges.setdefault(start, []).append(end) - traced = [start] - current, entered_from = start, 0 + for y, row in enumerate(rows): + if not row: + continue + above = rows[y - 1] if y else 0 + below = rows[y + 1] if y + 1 < height else 0 + for x in _set_bits(row & ~above): + edge((x, y), (x + 1, y)) + for x in _set_bits(row & ~(row >> 1)): + edge((x + 1, y), (x + 1, y + 1)) + for x in _set_bits(row & ~below): + edge((x + 1, y + 1), (x, y + 1)) + for x in _set_bits(row & ~(row << 1)): + edge((x, y + 1), (x, y)) + + start: Corner = (found[0][1], found[0][0]) + ring = [start] + current, heading = start, (1, 0) while True: - for step in range(1, len(around) + 1): - index = (entered_from + step) % len(around) - candidate = (current[0] + around[index][0], current[1] + around[index][1]) - if lit(candidate): - # The direction the *next* search resumes from: back the way we - # came, which is the opposite neighbour. - entered_from = (index + len(around) // 2) % len(around) - current = candidate - break - else: - # An isolated pixel has no ring to walk. - return [(float(start[0]), float(start[1]))] - if current == start: + options = edges[current] + following = ( + options.pop() if len(options) == 1 else _turned(options, at=current, heading=heading) + ) + heading = (following[0] - current[0], following[1] - current[1]) + if following == start: break - traced.append(current) - if len(traced) > 4 * (height + width): - # A boundary longer than any real one is a trace that failed to - # close. Returning what was walked beats looping. - break - return [(float(x), float(y)) for x, y in traced] + ring.append(following) + current = following + return [(float(x), float(y)) for x, y in ring] + + +def smoothed(ring: Sequence[Point]) -> list[Point]: + """One pass of corner cutting over a closed ring. + + Every edge is replaced by the two points a quarter and three quarters of the + way along it. On the unit-edge ring :func:`outline` produces, that turns a + staircase into a straight or gently curved line while moving no corner by + more than half a pixel — the cut is bounded by the edge length, and every + edge is one pixel long. Run on a ring whose straight runs had already been + merged into long edges it would round the real corners of a rectangle, which + is why it comes before any reduction. + + Every produced point lies on an edge of the ring it was given, so the result + never leaves the traced boundary and never crosses itself. + """ + if len(ring) < 3: + return list(ring) + out: list[Point] = [] + for index, (px, py) in enumerate(ring): + qx, qy = ring[(index + 1) % len(ring)] + out.append((0.75 * px + 0.25 * qx, 0.75 * py + 0.25 * qy)) + out.append((0.25 * px + 0.75 * qx, 0.25 * py + 0.75 * qy)) + return out def _distance_to_segment(point: Point, start: Point, end: Point) -> float: @@ -614,58 +639,49 @@ def simplified(points: Sequence[Point], *, tolerance: float) -> list[Point]: return [point for point, kept in zip(points, keep, strict=True) if kept] -def tolerance_for(points: Sequence[Point], *, detail: Detail = DEFAULT_DETAIL) -> float: - """The pixel tolerance ``detail`` means for a region of this size. - - See the module docstring: a fraction of the bounding diagonal, floored so it - never argues about sub-pixel detail. - """ - if not points: - return MINIMUM_TOLERANCE - xs = [x for x, _ in points] - ys = [y for _, y in points] - diagonal = ((max(xs) - min(xs)) ** 2 + (max(ys) - min(ys)) ** 2) ** 0.5 - return max(MINIMUM_TOLERANCE, EPSILON[detail] * diagonal) - - def contour(mask: Mask) -> list[Point]: - """Step 3 — the canonical boundary: traced, then reduced once at the floor. + """Step 3 — the canonical boundary: traced, smoothed, reduced once at the floor. The reduction is part of the definition rather than an optimisation, and the module docstring says why: Douglas-Peucker is not nested, so a client that re-simplifies and a server that stays authoritative can only be proved to - agree when both start here. At half a pixel it discards nothing a mask of - integer coordinates could express, and it turns a staircase of thousands of - single-pixel steps into the handful of segments those steps were drawing. + agree when both start here. At a quarter pixel it discards nothing the + smoothed ring could express, and it turns the tens of thousands of points a + large object's ring holds into the few thousand that describe its shape. """ - return simplified(outline(mask), tolerance=MINIMUM_TOLERANCE) + return simplified(smoothed(outline(mask)), tolerance=MINIMUM_TOLERANCE) def _closed(kept: list[Point], *, tolerance: float) -> list[Point]: - """Drop the vertices Douglas-Peucker only kept because it was told to. + """Drop the one vertex Douglas-Peucker only kept because it was told to. The algorithm pins the first and last point of what it is given, and what it - is given here is a *ring* cut open at an arbitrary pixel. So the final vertex + is given here is a *ring* cut open at an arbitrary corner. So the final vertex is pinned for a reason that stops being true the moment the ring closes, and - it lands one pixel from the first — a stray handle on an otherwise clean - outline, most visible on the straight-edged shapes where it is least + it lands a fraction of a pixel from the first — a stray handle on an otherwise + clean outline, most visible on the straight-edged shapes where it is least excusable: an axis-aligned rectangle came back as five points. Judged by the same tolerance as everything else rather than by exact equality: the artifact is a near-duplicate, not a duplicate, so testing ``kept[0] == kept[-1]`` never fires on the case that motivates it. + + Exactly one, and never a loop. Cutting the ring open pins exactly one vertex + artificially, and that vertex is a near-duplicate of the first — a fraction of + a pixel away, one edge-trace step after smoothing — so removing it barely moves + the closing segment and cannot push a contour point past the tolerance. A + second drop would be dropping a real corner the reduction chose to keep, and + the contour behind it would then sit further out than the polygon promises. """ - while len(kept) > 3: - if _distance_to_segment(kept[-1], kept[-2], kept[0]) > tolerance: - return kept - kept = kept[:-1] + if len(kept) > 3 and _distance_to_segment(kept[-1], kept[-2], kept[0]) <= tolerance: + return kept[:-1] return kept def polygon_at( - points: Sequence[Point], *, detail: Detail = DEFAULT_DETAIL + points: Sequence[Point], *, tolerance: float = DEFAULT_TOLERANCE ) -> PolygonGeometry | None: - """Step 4 — that contour at the requested vertex density, or ``None``. + """Step 4 — that contour within ``tolerance`` pixels, or ``None``. ``None`` covers a contour with nothing in it and one too thin to have three distinct corners: the domain requires three points, and a two-point "polygon" @@ -677,7 +693,6 @@ def polygon_at( """ if len(points) < 3: return None - tolerance = tolerance_for(points, detail=detail) kept = _closed(simplified(points, tolerance=tolerance), tolerance=tolerance) if len(kept) < 3: return None @@ -740,7 +755,7 @@ def shapes_from( mask: Mask, *, allowed: Sequence[GeometryType], - detail: Detail = DEFAULT_DETAIL, + tolerance: float = DEFAULT_TOLERANCE, at: Sequence[Point] = (), ) -> list[Shaped]: """The whole pipeline: a mask and a class's geometries in, proposals out. @@ -749,8 +764,8 @@ def shapes_from( first. A class that admits polygons gets the outline of the piece the prompt points at; one that admits only boxes gets a single box over every piece that survived the noise filter, measured off the mask's own extent rather than off - a simplified outline's corners — which is what keeps ``detail`` from quietly - moving a box. + a simplified outline's corners — which is what keeps the tolerance from + quietly moving a box. **The branch is before the close, and the close is paid for once.** A close only ever adds pixels whose whole neighbourhood was already reachable, so it @@ -764,8 +779,7 @@ def shapes_from( because an empty list is how "nothing to propose" is already said. **Nothing is ever widened.** A class admitting neither kind gets an empty - list, and a piece too thin to be a polygon is dropped rather than demoted to - a box: answering in a kind the caller did not ask for is how a suggestion + list: answering in a kind the caller did not ask for is how a suggestion arrives that the schema will refuse to store. """ kind = target_kind(allowed) @@ -783,5 +797,5 @@ def shapes_from( pointed = pieces[0] whole = Piece(x=pointed.x, y=pointed.y, mask=filled(pointed.mask)) traced = _shifted(contour(whole.mask), piece=whole) - polygon = polygon_at(traced, detail=detail) + polygon = polygon_at(traced, tolerance=tolerance) return [] if polygon is None else [Shaped(geometry=polygon, contour=tuple(traced))] diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index 80a3657d..a8777d31 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -214,9 +214,10 @@ grid_bounds, ) from visionset.kernel.domain.suggestion import ( - DEFAULT_DETAIL, + DEFAULT_TOLERANCE, + MAXIMUM_TOLERANCE, + MINIMUM_TOLERANCE, PARAMETER_APPLIES_TO, - Detail, SuggestParameter, suggest_parameters, ) @@ -424,10 +425,11 @@ "AssetSegmentation", "Mask", "SegmentedMask", - "Detail", "SuggestParameter", "PARAMETER_APPLIES_TO", - "DEFAULT_DETAIL", + "DEFAULT_TOLERANCE", + "MAXIMUM_TOLERANCE", + "MINIMUM_TOLERANCE", "suggest_parameters", "ActivityEntry", "ActivityKind", diff --git a/src/visionset/kernel/domain/suggestion.py b/src/visionset/kernel/domain/suggestion.py index 9f9ca22c..344a3de0 100644 --- a/src/visionset/kernel/domain/suggestion.py +++ b/src/visionset/kernel/domain/suggestion.py @@ -1,20 +1,19 @@ -# usage: from visionset.kernel.domain import Detail, suggest_parameters +# usage: from visionset.kernel.domain import DEFAULT_TOLERANCE, suggest_parameters """How a model's mask becomes a shape, said out loud so a client can offer it. A segmenter answers a click with a grid of booleans, and turning that grid into a polygon or a box is a chain of choices: which blobs to keep, whether to close the -holes inside them, how much of the traced outline survives. Most of those choices +holes inside them, how closely the outline follows the mask. Most of those choices are made once, with a fixed default, by the pipeline — and one of them is worth putting in front of the person looking at the proposal. This module is the vocabulary for the one. -**The names are this domain's, not an imaging library's.** ``detail`` is a -question about the shape somebody is going to edit; ``epsilon`` is a parameter of -one algorithm that happens to answer it. Naming the parameter after the algorithm -would publish an implementation as a contract and make a second implementation a -breaking change. +**The setting is a distance in the asset's own pixels.** The polygon stays within +that distance of the mask's outline, so the number means the same thing on a thing +eight pixels across and a thing eight hundred across, and a person reading it knows +what they will get before they move it. -**Applicability is declared, never derived.** ``detail`` is about an outline, so +**Applicability is declared, never derived.** ``tolerance`` is about an outline, so it means nothing for a class that stores a box; a client that worked that out for itself would be the hand-mirrored table ``capabilities.py`` exists to prevent, and it would drift the first time a parameter changed hands. So @@ -25,78 +24,59 @@ its noise specks are still done, at fixed defaults that live beside the pipeline in ``visionset.inference.masks``. They stopped being askable because on an ordinary single clean piece every position of either gave the same shape, so they -read as controls wired to nothing (#557). Their value is in the default rather -than in the choice; they come back as parameters if a real need for the choice -appears. +read as controls wired to nothing. Their value is in the default rather than +in the choice; they come back as parameters if a real need for the choice appears. Pure, and in the domain rather than beside the code that computes the pipeline, on ``capabilities.py``'s terms: a question about domain values, answered from a -domain table, with no I/O. What the members *numerically* mean is a property of -the simplification algorithm and lives beside it, the way ``ModelCapability`` -lives here and the family-to-capability mapping is declared by each driver that -satisfies it. +domain table, with no I/O. """ from __future__ import annotations from collections.abc import Mapping -from enum import StrEnum from typing import Final from visionset.kernel.domain.schema import GeometryType from visionset.kernel.domain.vocabulary import OpenVocabulary -# Three steps rather than a number, on ``Precision``'s test: the set is small, -# somebody choosing between them is choosing how much work a shape will be to -# edit rather than tuning a tolerance, and a free scalar on the wire would be a -# knob whose useful range only the implementation knows. -# -# Declaration order is display order, and it is coarsest-first so that `[` and -# `]` move the same direction as the list reads. -# -# The reasoning lives here rather than in the docstring because FastAPI copies a -# docstring verbatim into `openapi.json` as the schema's `description`, where an -# internal rationale is noise and RST markup renders as literal backticks. -class Detail(StrEnum): - """How much of an outline survives simplification. Order is display order.""" - - #: Fewest vertices — a shape to nudge into place rather than to trust. - COARSE = "coarse" - #: The middle setting, and the one every suggestion used before there was a choice. - BALANCED = "balanced" - #: Most vertices — follows the mask closely, and costs more to edit by hand. - FINE = "fine" - - # One member per parameter, and the table below owes every one of them a row. class SuggestParameter(OpenVocabulary): """A setting that shapes a suggestion. Order is display order.""" - DETAIL = "detail" + TOLERANCE = "tolerance" + +DEFAULT_TOLERANCE: Final = 1.0 +"""What a caller that says nothing gets: an outline within one pixel of the mask.""" + +MINIMUM_TOLERANCE: Final = 0.25 +"""The finest setting, and the floor the canonical contour is reduced at. + +A quarter of a pixel is finer than anything a mask of integer pixels can express +once its outline has been smoothed; below it the vertex count grows for nothing. +""" -DEFAULT_DETAIL: Final = Detail.BALANCED -"""What a caller that says nothing gets, and what every suggestion got before.""" +MAXIMUM_TOLERANCE: Final = 16.0 +"""The coarsest setting. Past sixteen pixels an outline stops describing the object.""" PARAMETER_APPLIES_TO: Final[Mapping[SuggestParameter, frozenset[GeometryType]]] = { - SuggestParameter.DETAIL: frozenset({GeometryType.POLYGON}), + SuggestParameter.TOLERANCE: frozenset({GeometryType.POLYGON}), } """Which geometries each parameter has any effect on. -``detail`` is about an *outline*: it decides how many vertices that outline keeps. -A box has no outline to spend a vertex budget on, so offering it for a box class -would be offering a control that does nothing. A box class therefore declares no -parameters at all, and a client shows it no adjustments — which is a rendering -rule the client reads rather than one it works out (#557). +``tolerance`` is about an *outline*: it decides how closely that outline follows +the mask. A box has no outline, so offering it for a box class would be offering +a control that does nothing. A box class therefore declares no parameters at all, +and a client shows it no adjustments — which is a rendering rule the client reads +rather than one it works out. **A parameter missing from this mapping is a test failure, not a default.** ``test_every_parameter_declares_the_geometries_it_applies_to`` sweeps ``SuggestParameter`` against these keys, so a second parameter arrives with its -applicability stated or it does not arrive. The alternative — treating an absent -row as "applies to everything" — is the one that ships a control nobody can use -and nobody notices. +applicability stated or it does not arrive. """ diff --git a/tests/inference/test_masks.py b/tests/inference/test_masks.py index 153cccc3..2869b4f7 100644 --- a/tests/inference/test_masks.py +++ b/tests/inference/test_masks.py @@ -29,11 +29,11 @@ polygon_at, shapes_from, simplified, + smoothed, spans, ) from visionset.kernel.domain import ( BboxGeometry, - Detail, GeometryType, PolygonGeometry, ) @@ -54,6 +54,22 @@ def disc(radius: int, *, width: int | None = None, height: int | None = None) -> ] +def blob(radius: int, lobes: int) -> list[list[bool]]: + """A lobed disc: an outline whose curvature keeps changing.""" + import math + + width = height = 2 * radius + 12 + cx, cy = width // 2, height // 2 + return [ + [ + ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 + <= radius * (1.0 + 0.18 * math.sin(math.atan2(y - cy, x - cx) * lobes)) + for x in range(width) + ] + for y in range(height) + ] + + def rect( x0: int, y0: int, x1: int, y1: int, *, width: int = 100, height: int = 100 ) -> list[list[bool]]: @@ -68,6 +84,18 @@ def lit(mask: list[list[bool]]) -> int: return sum(sum(1 for cell in row if cell) for row in mask) +def _gap_between(one: tuple[float, float], other: tuple[float, float]) -> float: + return ((one[0] - other[0]) ** 2 + (one[1] - other[1]) ** 2) ** 0.5 + + +def distance_to_ring(point: tuple[float, float], ring: list[tuple[float, float]]) -> float: + """How far a point is from the closed polyline through ``ring``.""" + return min( + masks._distance_to_segment(point, ring[index], ring[(index + 1) % len(ring)]) + for index in range(len(ring)) + ) + + # --- the extent --------------------------------------------------------------- @@ -296,32 +324,89 @@ def holed(hole: int) -> list[list[bool]]: # --- step 3: the canonical contour --------------------------------------------- -def test_the_outline_closes_on_itself() -> None: +def test_the_outline_is_the_pixels_edges_not_their_centres() -> None: + """A lone pixel is its unit square: the boundary of the mask, not a path through it.""" + assert outline(rect(3, 3, 3, 3)) == [(3.0, 3.0), (4.0, 3.0), (4.0, 4.0), (3.0, 4.0)] + + +def test_the_outline_walks_every_corner_of_the_ring_once() -> None: traced = outline(rect(10, 10, 20, 20)) assert traced[0] == (10.0, 10.0) - assert len(traced) == 40 # the perimeter of an 11x11 square, corners counted once - assert len(set(traced)) == len(traced), "no pixel is walked twice" + assert len(traced) == 44 # the perimeter of an 11x11 square, in unit edges + assert len(set(traced)) == len(traced) -def test_an_isolated_pixel_has_no_ring_to_walk() -> None: - assert outline(rect(3, 3, 3, 3)) == [(3.0, 3.0)] +def test_two_pixels_touching_only_at_a_corner_are_one_ring() -> None: + """The trace agrees with the 8-connected pieces `components` builds. + + At the pinch the walk turns onto the other pixel rather than closing round the + first, so the corner is visited twice and both squares are on the one ring. + """ + mask = [[False] * 5 for _ in range(5)] + mask[1][1] = True + mask[2][2] = True + traced = outline(mask) + assert len(traced) == 8 + assert len(set(traced)) == 7 + + +def test_an_enclosed_hole_does_not_reach_the_outline() -> None: + mask = rect(2, 2, 9, 9, width=12, height=12) + for y in range(4, 8): + for x in range(4, 8): + mask[y][x] = False + assert outline(mask) == outline(rect(2, 2, 9, 9, width=12, height=12)) + + +def test_smoothing_cuts_every_corner_by_a_quarter_of_its_edges() -> None: + square = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)] + assert smoothed(square) == [ + (1.0, 0.0), + (3.0, 0.0), + (4.0, 1.0), + (4.0, 3.0), + (3.0, 4.0), + (1.0, 4.0), + (0.0, 3.0), + (0.0, 1.0), + ] + +def test_smoothing_never_leaves_the_ring_it_was_given() -> None: + ring = outline(disc(20)) + for point in smoothed(ring): + assert distance_to_ring(point, ring) <= 0.5 -def test_the_contour_is_the_trace_already_reduced_at_the_floor() -> None: + +def test_smoothing_leaves_anything_shorter_than_a_ring_alone() -> None: + pair = [(0.0, 0.0), (1.0, 1.0)] + assert smoothed(pair) == pair + + +def test_the_contour_is_the_smoothed_trace_reduced_at_the_floor() -> None: """The definition, asserted as a definition rather than described. - Douglas-Peucker is not nested, so the editor and this module can only be + Simplification is not nested, so the editor and this module can only be proved to agree when both start from the same points. That makes the - half-pixel reduction part of what a contour *is*. + quarter-pixel reduction part of what a contour *is*. """ mask = rect(10, 10, 20, 20) - assert contour(mask) == simplified(outline(mask), tolerance=MINIMUM_TOLERANCE) + assert contour(mask) == simplified(smoothed(outline(mask)), tolerance=MINIMUM_TOLERANCE) + + +def test_a_square_keeps_its_corners_to_within_half_a_pixel() -> None: + """Smoothing works on unit edges, so a real corner moves by a fraction of a pixel.""" + traced = contour(rect(10, 10, 60, 60)) + assert len(traced) <= 9 + for corner in ((10.0, 10.0), (61.0, 10.0), (61.0, 61.0), (10.0, 61.0)): + assert min(_gap_between(corner, point) for point in traced) <= 0.5 -def test_the_floor_costs_a_square_none_of_its_corners() -> None: - """It throws away staircase, not shape: 40 traced pixels, 4 corners plus the seam.""" - assert len(outline(rect(10, 10, 20, 20))) == 40 - assert len(contour(rect(10, 10, 20, 20))) == 5 +def test_a_staircase_becomes_one_straight_edge() -> None: + triangle = [[x <= y for x in range(40)] for y in range(40)] + polygon = polygon_at(contour(triangle), tolerance=1.0) + assert polygon is not None + assert len(polygon.points) == 3 def test_an_empty_mask_has_no_contour() -> None: @@ -347,48 +432,51 @@ def test_a_contour_too_thin_to_be_a_polygon_is_refused() -> None: assert polygon_at([]) is None -@pytest.mark.parametrize("radius", [8, 15, 30, 60, 120, 300]) -def test_a_typical_object_lands_in_the_ten_to_forty_vertex_band(radius: int) -> None: - """The range, and the property that says the tolerance is relative rather than absolute. +TOLERANCES = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0] - The same detail setting has to work on a thing eight pixels across and a - thing six hundred across, which an absolute pixel tolerance cannot do: three - pixels is nothing on a car and is the whole of a bottle cap. Asserting the - band across a 37x size range is what would fail if the tolerance stopped - scaling with the region. - """ - polygon = polygon_at(contour(disc(radius))) + +@pytest.mark.parametrize("tolerance", TOLERANCES) +@pytest.mark.parametrize("shape", ["disc", "blob", "rectangle"]) +def test_every_contour_point_is_within_the_tolerance_of_the_polygon( + shape: str, tolerance: float +) -> None: + """The promise the setting makes, asserted as a bound rather than a count.""" + if shape == "rectangle": + mask = rect(10, 10, 60, 60) + else: + mask = disc(250) if shape == "disc" else blob(250, 7) + traced = contour(filled(mask)) + polygon = polygon_at(traced, tolerance=tolerance) assert polygon is not None - assert 10 <= len(polygon.points) <= 40 + for point in traced: + assert distance_to_ring(point, polygon.points) <= tolerance + 1e-9 -def test_a_rectangle_comes_back_as_exactly_its_corners() -> None: - """The closing artifact, pinned. +def test_a_tighter_tolerance_keeps_more_of_the_outline() -> None: + traced = contour(disc(250)) + counts = [len(polygon_at(traced, tolerance=t).points) for t in (8.0, 2.0, 1.0, 0.5)] # type: ignore[union-attr] + assert counts == sorted(counts) + assert counts[0] < counts[-1] - Douglas-Peucker pins the last point of what it is given, and what it is given - is a ring cut open at an arbitrary pixel — so the final vertex is pinned for - a reason that stops being true once the ring closes, landing one pixel from - the first. This asserts the near-duplicate is gone, which an equality check - on first-versus-last would never catch, because it is not a duplicate. - """ - polygon = polygon_at(contour(rect(10, 10, 60, 60))) + +def test_a_large_smooth_object_at_one_pixel_is_no_longer_a_handful_of_vertices() -> None: + polygon = polygon_at(contour(disc(250)), tolerance=1.0) assert polygon is not None - assert polygon.points == [(10.0, 10.0), (60.0, 10.0), (60.0, 60.0), (10.0, 60.0)] + assert len(polygon.points) > 40 -@pytest.mark.parametrize("radius", [30, 60, 120]) -def test_the_three_steps_are_ordered_and_tell_each_other_apart(radius: int) -> None: - """Finer keeps more than balanced, which keeps more than coarse. +def test_a_rectangle_comes_back_as_its_four_corners() -> None: + """The closing artifact, pinned. - Strictly, at every size in the band: three settings that collapsed onto two - at some scale would be a control with a dead position. + Douglas-Peucker pins the last point of what it is given, and what it is given + is a ring cut open at an arbitrary corner — so the final vertex is pinned for + a reason that stops being true once the ring closes. This asserts the + near-duplicate is gone, which an equality check on first-versus-last would + never catch, because it is not a duplicate. """ - traced = contour(disc(radius)) - counts = [ - len(polygon_at(traced, detail=step).points) # type: ignore[union-attr] - for step in (Detail.COARSE, Detail.BALANCED, Detail.FINE) - ] - assert counts[0] < counts[1] < counts[2], counts + polygon = polygon_at(contour(rect(10, 10, 60, 60)), tolerance=1.0) + assert polygon is not None + assert polygon.points == [(10.25, 10.0), (60.75, 10.0), (61.0, 60.75), (10.25, 61.0)] # --- the whole pipeline, and the kinds a class admits -------------------------- @@ -398,7 +486,7 @@ def test_a_polygon_stands_where_polygons_are_allowed() -> None: shaped = shapes_from(speckled(), allowed=BOTH, at=[(3.0, 5.0)]) assert len(shaped) == 1 assert shaped[0].geometry == PolygonGeometry( - points=[(1.0, 3.0), (6.0, 3.0), (6.0, 8.0), (1.0, 8.0)] + points=[(1.25, 3.0), (6.75, 3.0), (7.0, 8.75), (1.25, 9.0)] ) @@ -414,17 +502,17 @@ def contour_in_asset() -> list[tuple[float, float]]: def test_a_box_class_gets_the_extent_and_not_a_reduced_outlines_corners() -> None: - """The branch after hole filling, which is what keeps `detail` off a box.""" + """The branch after hole filling, which is what keeps the tolerance off a box.""" shaped = shapes_from(speckled(), allowed=BOX_ONLY, at=[(3.0, 5.0)]) assert len(shaped) == 1 assert shaped[0].geometry == BboxGeometry(x=1.0, y=3.0, width=6.0, height=6.0) assert shaped[0].contour == (), "there is nothing for a client to re-derive" -@pytest.mark.parametrize("step", list(Detail), ids=lambda d: d.value) -def test_a_box_does_not_move_when_detail_does(step: Detail) -> None: +@pytest.mark.parametrize("tolerance", TOLERANCES) +def test_a_box_does_not_move_when_the_tolerance_does(tolerance: float) -> None: """ "Applies to polygon only", asserted as behaviour rather than as a table row.""" - shaped = shapes_from(disc(40), allowed=BOX_ONLY, detail=step) + shaped = shapes_from(disc(40), allowed=BOX_ONLY, tolerance=tolerance) assert shaped[0].geometry == shapes_from(disc(40), allowed=BOX_ONLY)[0].geometry @@ -433,9 +521,22 @@ def test_a_class_admitting_neither_is_offered_nothing() -> None: assert shapes_from(speckled(), allowed=[GeometryType.CLASSIFICATION_TAG]) == [] -def test_a_piece_too_thin_to_be_a_polygon_is_dropped_rather_than_demoted() -> None: - """Nothing is ever widened, and nothing is answered in a kind nobody asked for.""" - assert shapes_from(rect(10, 10, 11, 10), allowed=BOTH) == [] +def test_a_two_pixel_piece_is_its_own_small_rectangle() -> None: + """Nothing is widened and nothing is demoted: the smallest piece is still a polygon. + + Tracing along the pixels' edges gives every non-empty piece four corners, so + the noise filter is what guards against specks and degeneracy never has to. + Asserted at the floor because a tolerance coarser than the object is entitled + to flatten it — a two-pixel thing is a pixel tall. + """ + shaped = shapes_from(rect(10, 10, 11, 10), allowed=BOTH, tolerance=MINIMUM_TOLERANCE) + assert len(shaped) == 1 + polygon = shaped[0].geometry + assert isinstance(polygon, PolygonGeometry) + assert len(polygon.points) == 4 + corners = ((10.0, 10.0), (12.0, 10.0), (12.0, 11.0), (10.0, 11.0)) + for point in polygon.points: + assert min(_gap_between(point, corner) for corner in corners) <= 0.5 def test_a_polygon_is_the_piece_that_was_clicked_and_only_that_piece() -> None: diff --git a/tests/kernel/test_suggestion_parameters.py b/tests/kernel/test_suggestion_parameters.py index 8ff72086..c6168a03 100644 --- a/tests/kernel/test_suggestion_parameters.py +++ b/tests/kernel/test_suggestion_parameters.py @@ -14,9 +14,10 @@ import pytest from visionset.kernel.domain import ( - DEFAULT_DETAIL, + DEFAULT_TOLERANCE, + MAXIMUM_TOLERANCE, + MINIMUM_TOLERANCE, PARAMETER_APPLIES_TO, - Detail, GeometryType, SuggestParameter, suggest_parameters, @@ -49,13 +50,13 @@ def test_a_declared_geometry_is_one_the_domain_actually_stores( def test_a_polygon_is_offered_every_parameter() -> None: - assert suggest_parameters(GeometryType.POLYGON) == (SuggestParameter.DETAIL,) + assert suggest_parameters(GeometryType.POLYGON) == (SuggestParameter.TOLERANCE,) def test_a_box_is_offered_nothing_at_all() -> None: - # `detail` changes an outline and a box has none, so a box class declares no - # parameters — which is what tells a client to render no adjustments rather - # than an empty section (#557). + # The tolerance shapes an outline and a box has none, so a box class declares + # no parameters — which is what tells a client to render no adjustments rather + # than an empty section. assert suggest_parameters(GeometryType.BBOX) == () @@ -71,5 +72,12 @@ def test_the_reader_answers_in_declaration_order() -> None: assert list(offered) == [p for p in SuggestParameter if p in offered] -def test_the_default_is_a_member_of_its_own_vocabulary() -> None: - assert DEFAULT_DETAIL in Detail +def test_the_default_tolerance_sits_inside_its_own_range() -> None: + assert MINIMUM_TOLERANCE < DEFAULT_TOLERANCE < MAXIMUM_TOLERANCE + + +def test_the_range_is_wide_enough_to_double_from_the_default_in_both_directions() -> None: + # `[` and `]` walk a doubling ladder from the default; a range that could not + # be doubled at least once each way would have a dead bracket on arrival. + assert DEFAULT_TOLERANCE * 2 <= MAXIMUM_TOLERANCE + assert DEFAULT_TOLERANCE / 2 >= MINIMUM_TOLERANCE From 0be93eb055110b4712be827be240874794544a9b Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Tue, 25 Aug 2026 07:30:41 -0700 Subject: [PATCH 2/7] feat(api): suggest takes a pixel tolerance in place of detail SuggestRequest.tolerance defaults to 1.0 and is refused outside 0.25-16, never clamped; applied.tolerance echoes it and parameters names it for a polygon class. detail is an unknown field. openapi.json and the generated client are regenerated. --- frontend/ui-core/src/generated/api.ts | 36 ++++++++-------- frontend/ui-core/src/generated/checks.ts | 7 +--- openapi.json | 34 +++++++--------- src/visionset/inference/suggestions.py | 9 ++-- src/visionset/server/models.py | 20 +++++---- src/visionset/server/routes/inference.py | 18 ++++---- tests/server/test_suggest.py | 52 ++++++++++++++++-------- 7 files changed, 91 insertions(+), 85 deletions(-) diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts index 38681deb..6d8703ac 100644 --- a/frontend/ui-core/src/generated/api.ts +++ b/frontend/ui-core/src/generated/api.ts @@ -1276,21 +1276,21 @@ export interface paths { * alone; sending both would answer past the tool they are holding, and nothing * on their screen would have said so. * - * **`detail` is the one setting, and it does not reach the model.** It decides - * how much of an outline survives simplification. It is optional and defaults - * to `balanced`, which is what every suggestion used before there was a choice. - * Closing the small gaps in a mask and dropping its noise specks still happen, - * at fixed defaults nobody asks for. + * **`tolerance` is the one setting, and it does not reach the model.** It is a + * distance in the asset's pixels: every point of the traced outline lies within + * it of the polygon returned. Optional, defaulting to `1.0`; refused outside + * `[0.25, 16]` rather than clamped. Closing the small gaps in a mask and + * dropping its noise specks still happen, at fixed defaults nobody asks for. * * **`parameters` says which settings apply here**, for the kind of shape this - * request will come back in. It is empty for a box class — `detail` changes an + * request will come back in. It is empty for a box class — the tolerance shapes an * outline and a box has none — which is how a client is told to render no * adjustments at all. It is present even when there is nothing to propose, so * somebody who adjusted their way into an empty answer can adjust their way * back out. A client renders what this names and works none of it out itself. * * **`contour` on each region is the outline the shape was reduced from.** It is - * what lets a client re-run `detail` locally rather than asking again, and it + * what lets a client re-run the tolerance locally rather than asking again, and it * is the *same* points this route reduced — simplification is not nested, so a * client starting from anything else could not be held to the same answer. A * box carries none, because there is nothing it was reduced from. @@ -2911,7 +2911,8 @@ export interface components { * @description The parameter values this answer was actually produced with. */ AppliedParameters: { - detail: components["schemas"]["Detail"]; + /** Tolerance */ + tolerance: number; }; /** * AssetAction @@ -3792,12 +3793,6 @@ export interface components { */ dataset_id: string; }; - /** - * Detail - * @description How much of an outline survives simplification. Order is display order. - * @enum {string} - */ - Detail: "coarse" | "balanced" | "fine"; /** * DownloadSizeOut * @description What fetching a model's weights would cost, before anybody fetches them. @@ -5060,7 +5055,7 @@ export interface components { * @description A setting that shapes a suggestion. Order is display order. * @enum {string} */ - SuggestParameter: "detail" | (string & {}); + SuggestParameter: "tolerance" | (string & {}); /** * SuggestPoint * @description One click, in the asset's own pixel coordinates. @@ -5104,8 +5099,6 @@ export interface components { * Format: uuid */ connection_id: string; - /** @default balanced */ - detail: components["schemas"]["Detail"]; /** Negative */ negative?: components["schemas"]["SuggestPoint"][]; /** Positive */ @@ -5115,6 +5108,11 @@ export interface components { * Format: uuid */ project_id: string; + /** + * Tolerance + * @default 1 + */ + tolerance: number; }; /** * SuggestedRegion @@ -5136,7 +5134,7 @@ export interface components { * `regions` is empty when there is no suggestion, and that is an ordinary * answer rather than an error: a click can land on sky, the model can be less * sure than the caller asked for, the shape found can be one this class cannot - * hold, and the detail as set can leave nothing. A 404 or a 409 for any of + * hold, and the tolerance as set can leave nothing. A 404 or a 409 for any of * those would be telling the caller they did something wrong when they did not. * * `model_ref` is echoed on every answer, including the empty one, because it @@ -11746,5 +11744,5 @@ export interface KnownMembers { JobAction: "start" | "complete"; ModelCapability: "point_suggest" | "text_detect"; PreLabelExclusionReason: "no_producible_geometry" | "required_attribute"; - SuggestParameter: "detail"; + SuggestParameter: "tolerance"; } diff --git a/frontend/ui-core/src/generated/checks.ts b/frontend/ui-core/src/generated/checks.ts index e1b2f4b4..f2b43697 100644 --- a/frontend/ui-core/src/generated/checks.ts +++ b/frontend/ui-core/src/generated/checks.ts @@ -348,14 +348,11 @@ export const checkSourcePage: Check = export const checkSplitAssignmentOut: Check = /*#__PURE__*/ object({ "test": [true, arrayOf(isString)], "train": [true, arrayOf(isString)], "val": [true, arrayOf(isString)] } as const); -export const checkDetail: Check = - /*#__PURE__*/ oneOf(["coarse", "balanced", "fine"] as const); - export const checkAppliedParameters: Check = - /*#__PURE__*/ object({ "detail": [true, checkDetail] } as const); + /*#__PURE__*/ object({ "tolerance": [true, isNumber] } as const); export const checkSuggestParameter: Check = - /*#__PURE__*/ openOneOf(["detail"] as const); + /*#__PURE__*/ openOneOf(["tolerance"] as const); export const checkBboxGeometry: Check = /*#__PURE__*/ object({ "height": [true, isNumber], "type": [true, lit("bbox")], "width": [true, isNumber], "x": [true, isNumber], "y": [true, isNumber] } as const); diff --git a/openapi.json b/openapi.json index 4df0faa7..97aec23b 100644 --- a/openapi.json +++ b/openapi.json @@ -426,12 +426,13 @@ "AppliedParameters": { "description": "The parameter values this answer was actually produced with.", "properties": { - "detail": { - "$ref": "#/components/schemas/Detail" + "tolerance": { + "title": "Tolerance", + "type": "number" } }, "required": [ - "detail" + "tolerance" ], "title": "AppliedParameters", "type": "object" @@ -2606,16 +2607,6 @@ "title": "DatasetStatsOut", "type": "object" }, - "Detail": { - "description": "How much of an outline survives simplification. Order is display order.", - "enum": [ - "coarse", - "balanced", - "fine" - ], - "title": "Detail", - "type": "string" - }, "DownloadSizeOut": { "description": "What fetching a model's weights would cost, before anybody fetches them.\n\nAnswered from the publishing hub's file listing, so asking costs a metadata\nrequest and never a download. The pair is echoed back for ``SuggestionOut``'s\nreason: a form that had to remember which model it asked about would be\nkeeping a second copy of something the response can simply state.", "properties": { @@ -5035,7 +5026,7 @@ "SuggestParameter": { "description": "A setting that shapes a suggestion. Order is display order.", "enum": [ - "detail" + "tolerance" ], "title": "SuggestParameter", "type": "string", @@ -5083,10 +5074,6 @@ "title": "Connection Id", "type": "string" }, - "detail": { - "$ref": "#/components/schemas/Detail", - "default": "balanced" - }, "negative": { "items": { "$ref": "#/components/schemas/SuggestPoint" @@ -5106,6 +5093,13 @@ "format": "uuid", "title": "Project Id", "type": "string" + }, + "tolerance": { + "default": 1.0, + "maximum": 16.0, + "minimum": 0.25, + "title": "Tolerance", + "type": "number" } }, "required": [ @@ -5173,7 +5167,7 @@ "type": "object" }, "SuggestionOut": { - "description": "What the model proposes, or an honest nothing.\n\n`regions` is empty when there is no suggestion, and that is an ordinary\nanswer rather than an error: a click can land on sky, the model can be less\nsure than the caller asked for, the shape found can be one this class cannot\nhold, and the detail as set can leave nothing. A 404 or a 409 for any of\nthose would be telling the caller they did something wrong when they did not.\n\n`model_ref` is echoed on every answer, including the empty one, because it\nis what an accepted suggestion has to carry into its annotation \u2014 and a\ncaller that had to remember which connection it asked would be keeping a\nsecond copy of something the response can simply state. `confidence` is the\nsame: one number for the answer, because the model scored one mask and the\npieces cut out of it are that same claim seen in parts.\n\n`parameters` names which settings have any effect on the kind of shape this\nrequest will come back in, so a client renders exactly those and works none\nof it out for itself. It is empty for a box class, which is how a client is\ntold to render no adjustments at all. It is present on an empty answer too,\nwhich is what lets somebody who adjusted their way into nothing adjust their\nway back out.", + "description": "What the model proposes, or an honest nothing.\n\n`regions` is empty when there is no suggestion, and that is an ordinary\nanswer rather than an error: a click can land on sky, the model can be less\nsure than the caller asked for, the shape found can be one this class cannot\nhold, and the tolerance as set can leave nothing. A 404 or a 409 for any of\nthose would be telling the caller they did something wrong when they did not.\n\n`model_ref` is echoed on every answer, including the empty one, because it\nis what an accepted suggestion has to carry into its annotation \u2014 and a\ncaller that had to remember which connection it asked would be keeping a\nsecond copy of something the response can simply state. `confidence` is the\nsame: one number for the answer, because the model scored one mask and the\npieces cut out of it are that same claim seen in parts.\n\n`parameters` names which settings have any effect on the kind of shape this\nrequest will come back in, so a client renders exactly those and works none\nof it out for itself. It is empty for a box class, which is how a client is\ntold to render no adjustments at all. It is present on an empty answer too,\nwhich is what lets somebody who adjusted their way into nothing adjust their\nway back out.", "properties": { "applied": { "$ref": "#/components/schemas/AppliedParameters" @@ -9107,7 +9101,7 @@ }, "/inference/suggest": { "post": { - "description": "Propose a shape for the thing under those points.\n\nThe server side of the editor's suggest gesture. One asset, one prompt set,\none answer \u2014 batch prediction is a separate path and is not this one.\n\n**Nothing is written and nothing is remembered.** A suggestion is a proposal:\naccepting it is a later, ordinary annotation write carrying `provenance:\nmodel`, this response's `model_ref`, and its `confidence`. Discarding it\ncosts a request that already finished. The only thing that outlives the call\nis a cached image embedding, which is an optimisation rather than a record \u2014\nso the same points sent twice answer the same way, and a restart changes\nnothing but the latency of the first click.\n\n**The first click on an asset is the slow one.** A segmenter reads the whole\nimage once and then answers any number of clicks from that reading almost for\nfree, which is what makes refining by adding points practical. Sending the\naccumulated points \u2014 rather than a diff \u2014 is what keeps this stateless.\n\n**`allowed_geometries` is bounded by the caller's schema, and chosen within\nit.** The answer is produced in one of the kinds named or not at all: naming\npolygon gets the outline of the piece under the click, naming only box gets\none box over every piece the mask kept, and naming neither gets no regions.\nAnswering in a kind the schema would refuse would produce a suggestion that\ncannot be accepted, so every kind sent must be one the active class admits.\n\nWhich of them to send is the caller's decision, and it matters because **this\nroute prefers the polygon whenever both are named**. A client whose user is\nholding a box tool over a class that also accepts polygons sends `[\"bbox\"]`\nalone; sending both would answer past the tool they are holding, and nothing\non their screen would have said so.\n\n**`detail` is the one setting, and it does not reach the model.** It decides\nhow much of an outline survives simplification. It is optional and defaults\nto `balanced`, which is what every suggestion used before there was a choice.\nClosing the small gaps in a mask and dropping its noise specks still happen,\nat fixed defaults nobody asks for.\n\n**`parameters` says which settings apply here**, for the kind of shape this\nrequest will come back in. It is empty for a box class \u2014 `detail` changes an\noutline and a box has none \u2014 which is how a client is told to render no\nadjustments at all. It is present even when there is nothing to propose, so\nsomebody who adjusted their way into an empty answer can adjust their way\nback out. A client renders what this names and works none of it out itself.\n\n**`contour` on each region is the outline the shape was reduced from.** It is\nwhat lets a client re-run `detail` locally rather than asking again, and it\nis the *same* points this route reduced \u2014 simplification is not nested, so a\nclient starting from anything else could not be held to the same answer. A\nbox carries none, because there is nothing it was reduced from.\n\n**Every point must be on the asset**, positive and negative alike \u2014 `x` in\n`[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own\npixel frame. One point off the picture refuses the whole request with 422\n`PROMPT_POINT_OUT_OF_BOUNDS` rather than being dropped, because a gesture\nwith a point removed is a different gesture. Nothing is clamped: a\ncoordinate outside the frame is not a place on the image, and answering\nabout the nearest edge instead would return a mask, and a confidence, for a\nquestion nobody asked.\n\nAn empty `regions` is a successful answer with nothing to propose. These\nrefusals are about the request, and the caller can act on each: an unknown\nproject, asset or connection is 404 \u2014 `PROJECT_NOT_FOUND`, `ASSET_NOT_FOUND`\nor `INFERENCE_CONNECTION_NOT_FOUND`; a connection whose weights are not here\nyet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` and names what to do; a\nconnection whose model answers words rather than places is 422\n`UNSUPPORTED_PROMPT`, as is a prompt point off the asset; an `http`\nconnection whose endpoint does not answer the contract is 502\n`INFERENCE_ENDPOINT_UNAVAILABLE`.\n\nThree failures are about this installation rather than about the request,\nand answer 500 carrying the message that says which: a connection of a kind\nthis build ships no adapter for is `INFERENCE_CONNECTION_NOT_RUNNABLE`, a\nmachine without the optional local runtime is `LOCAL_INFERENCE_UNAVAILABLE`\nand carries the command that installs it, and a model that will not fit the\ndevice it was asked to run on is `INFERENCE_OUT_OF_MEMORY`. None of the\nthree is worth resending unchanged: there is no state here to change, so the\nremedy is the one the message names \u2014 an install, a different device, a\nsmaller model, or a build that ships the adapter.", + "description": "Propose a shape for the thing under those points.\n\nThe server side of the editor's suggest gesture. One asset, one prompt set,\none answer \u2014 batch prediction is a separate path and is not this one.\n\n**Nothing is written and nothing is remembered.** A suggestion is a proposal:\naccepting it is a later, ordinary annotation write carrying `provenance:\nmodel`, this response's `model_ref`, and its `confidence`. Discarding it\ncosts a request that already finished. The only thing that outlives the call\nis a cached image embedding, which is an optimisation rather than a record \u2014\nso the same points sent twice answer the same way, and a restart changes\nnothing but the latency of the first click.\n\n**The first click on an asset is the slow one.** A segmenter reads the whole\nimage once and then answers any number of clicks from that reading almost for\nfree, which is what makes refining by adding points practical. Sending the\naccumulated points \u2014 rather than a diff \u2014 is what keeps this stateless.\n\n**`allowed_geometries` is bounded by the caller's schema, and chosen within\nit.** The answer is produced in one of the kinds named or not at all: naming\npolygon gets the outline of the piece under the click, naming only box gets\none box over every piece the mask kept, and naming neither gets no regions.\nAnswering in a kind the schema would refuse would produce a suggestion that\ncannot be accepted, so every kind sent must be one the active class admits.\n\nWhich of them to send is the caller's decision, and it matters because **this\nroute prefers the polygon whenever both are named**. A client whose user is\nholding a box tool over a class that also accepts polygons sends `[\"bbox\"]`\nalone; sending both would answer past the tool they are holding, and nothing\non their screen would have said so.\n\n**`tolerance` is the one setting, and it does not reach the model.** It is a\ndistance in the asset's pixels: every point of the traced outline lies within\nit of the polygon returned. Optional, defaulting to `1.0`; refused outside\n`[0.25, 16]` rather than clamped. Closing the small gaps in a mask and\ndropping its noise specks still happen, at fixed defaults nobody asks for.\n\n**`parameters` says which settings apply here**, for the kind of shape this\nrequest will come back in. It is empty for a box class \u2014 the tolerance shapes an\noutline and a box has none \u2014 which is how a client is told to render no\nadjustments at all. It is present even when there is nothing to propose, so\nsomebody who adjusted their way into an empty answer can adjust their way\nback out. A client renders what this names and works none of it out itself.\n\n**`contour` on each region is the outline the shape was reduced from.** It is\nwhat lets a client re-run the tolerance locally rather than asking again, and it\nis the *same* points this route reduced \u2014 simplification is not nested, so a\nclient starting from anything else could not be held to the same answer. A\nbox carries none, because there is nothing it was reduced from.\n\n**Every point must be on the asset**, positive and negative alike \u2014 `x` in\n`[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own\npixel frame. One point off the picture refuses the whole request with 422\n`PROMPT_POINT_OUT_OF_BOUNDS` rather than being dropped, because a gesture\nwith a point removed is a different gesture. Nothing is clamped: a\ncoordinate outside the frame is not a place on the image, and answering\nabout the nearest edge instead would return a mask, and a confidence, for a\nquestion nobody asked.\n\nAn empty `regions` is a successful answer with nothing to propose. These\nrefusals are about the request, and the caller can act on each: an unknown\nproject, asset or connection is 404 \u2014 `PROJECT_NOT_FOUND`, `ASSET_NOT_FOUND`\nor `INFERENCE_CONNECTION_NOT_FOUND`; a connection whose weights are not here\nyet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` and names what to do; a\nconnection whose model answers words rather than places is 422\n`UNSUPPORTED_PROMPT`, as is a prompt point off the asset; an `http`\nconnection whose endpoint does not answer the contract is 502\n`INFERENCE_ENDPOINT_UNAVAILABLE`.\n\nThree failures are about this installation rather than about the request,\nand answer 500 carrying the message that says which: a connection of a kind\nthis build ships no adapter for is `INFERENCE_CONNECTION_NOT_RUNNABLE`, a\nmachine without the optional local runtime is `LOCAL_INFERENCE_UNAVAILABLE`\nand carries the command that installs it, and a model that will not fit the\ndevice it was asked to run on is `INFERENCE_OUT_OF_MEMORY`. None of the\nthree is worth resending unchanged: there is no state here to change, so the\nremedy is the one the message names \u2014 an install, a different device, a\nsmaller model, or a build that ships the adapter.", "operationId": "suggest_region", "requestBody": { "content": { diff --git a/src/visionset/inference/suggestions.py b/src/visionset/inference/suggestions.py index 435512d0..57f97cd8 100644 --- a/src/visionset/inference/suggestions.py +++ b/src/visionset/inference/suggestions.py @@ -32,8 +32,7 @@ from visionset.inference.masks import Point, Shaped, shapes_from, target_kind from visionset.inference.providers import ProviderPool, resident from visionset.kernel.domain import ( - DEFAULT_DETAIL, - Detail, + DEFAULT_TOLERANCE, GeometryType, PointPrompt, PredictionRequest, @@ -87,7 +86,7 @@ def suggest( connection_id: UUID, prompt: PointPrompt, allowed: tuple[GeometryType, ...], - detail: Detail = DEFAULT_DETAIL, + tolerance: float = DEFAULT_TOLERANCE, minimum_confidence: float = 0.0, pool: ProviderPool | None = None, ) -> Suggestion: @@ -101,7 +100,7 @@ def suggest( An empty ``shapes`` is a real answer and not a failure: the model was asked about a patch of sky, or was not sure enough, or the shape it found cannot be - expressed in the kinds this class admits, or the detail as set leaves + expressed in the kinds this class admits, or the tolerance as set leaves nothing. Every one of those is "no suggestion", and none of them is an error somebody made. @@ -163,7 +162,7 @@ def suggest( segment = answer.segments[0] at: tuple[Point, ...] = tuple(prompt.positive) - shapes = shapes_from(segment.mask, allowed=allowed, detail=detail, at=at) + shapes = shapes_from(segment.mask, allowed=allowed, tolerance=tolerance, at=at) return Suggestion( model_ref=answer.model_ref, shapes=tuple(shapes), diff --git a/src/visionset/server/models.py b/src/visionset/server/models.py index ee197620..33ac6b4e 100644 --- a/src/visionset/server/models.py +++ b/src/visionset/server/models.py @@ -54,7 +54,9 @@ produces_of, ) from visionset.kernel.domain import ( - DEFAULT_DETAIL, + DEFAULT_TOLERANCE, + MAXIMUM_TOLERANCE, + MINIMUM_TOLERANCE, ActivityEntry, ActivityKind, Annotation, @@ -90,7 +92,6 @@ Dataset, DatasetChange, DatasetStats, - Detail, DownloadSize, DraftAttribute, DraftLabelClass, @@ -2682,9 +2683,10 @@ class SuggestRequest(BaseModel): #: server prefers the polygon, so a caller holding a box tool narrows this to #: `["bbox"]` rather than letting the preference decide against it. allowed_geometries: list[GeometryType] = Field(min_length=1) - #: How much of an outline survives simplification. Omitted means `balanced`, - #: which is what every suggestion used before there was a choice. - detail: Detail = DEFAULT_DETAIL + #: How closely the outline follows the mask, in the asset's own pixels: every + #: point of the traced outline lies within this distance of the polygon. + #: Omitted means `1.0`. Refused outside `[0.25, 16]`, never clamped. + tolerance: float = Field(default=DEFAULT_TOLERANCE, ge=MINIMUM_TOLERANCE, le=MAXIMUM_TOLERANCE) class SuggestedRegion(BaseModel): @@ -2692,8 +2694,8 @@ class SuggestedRegion(BaseModel): geometry: Geometry #: The outline the shape was reduced from, in the asset's own pixels — what - #: lets a client re-run `detail` locally instead of asking again. Already - #: reduced once at the half-pixel floor, which is what makes the client's + #: lets a client re-run the tolerance locally instead of asking again. Already + #: reduced once at the quarter-pixel floor, which is what makes the client's #: answer and the server's provably the same: simplification is not nested, #: so both have to start from identical points. #: Empty for a box, which is an extent rather than something reduced from @@ -2707,7 +2709,7 @@ class SuggestedRegion(BaseModel): class AppliedParameters(BaseModel): """The parameter values this answer was actually produced with.""" - detail: Detail + tolerance: float class SuggestionOut(BaseModel): @@ -2716,7 +2718,7 @@ class SuggestionOut(BaseModel): `regions` is empty when there is no suggestion, and that is an ordinary answer rather than an error: a click can land on sky, the model can be less sure than the caller asked for, the shape found can be one this class cannot - hold, and the detail as set can leave nothing. A 404 or a 409 for any of + hold, and the tolerance as set can leave nothing. A 404 or a 409 for any of those would be telling the caller they did something wrong when they did not. `model_ref` is echoed on every answer, including the empty one, because it diff --git a/src/visionset/server/routes/inference.py b/src/visionset/server/routes/inference.py index dbad6083..15e9a529 100644 --- a/src/visionset/server/routes/inference.py +++ b/src/visionset/server/routes/inference.py @@ -406,21 +406,21 @@ def suggest_region(workspace: WorkspaceDep, body: SuggestRequest) -> SuggestionO alone; sending both would answer past the tool they are holding, and nothing on their screen would have said so. - **`detail` is the one setting, and it does not reach the model.** It decides - how much of an outline survives simplification. It is optional and defaults - to `balanced`, which is what every suggestion used before there was a choice. - Closing the small gaps in a mask and dropping its noise specks still happen, - at fixed defaults nobody asks for. + **`tolerance` is the one setting, and it does not reach the model.** It is a + distance in the asset's pixels: every point of the traced outline lies within + it of the polygon returned. Optional, defaulting to `1.0`; refused outside + `[0.25, 16]` rather than clamped. Closing the small gaps in a mask and + dropping its noise specks still happen, at fixed defaults nobody asks for. **`parameters` says which settings apply here**, for the kind of shape this - request will come back in. It is empty for a box class — `detail` changes an + request will come back in. It is empty for a box class — the tolerance shapes an outline and a box has none — which is how a client is told to render no adjustments at all. It is present even when there is nothing to propose, so somebody who adjusted their way into an empty answer can adjust their way back out. A client renders what this names and works none of it out itself. **`contour` on each region is the outline the shape was reduced from.** It is - what lets a client re-run `detail` locally rather than asking again, and it + what lets a client re-run the tolerance locally rather than asking again, and it is the *same* points this route reduced — simplification is not nested, so a client starting from anything else could not be held to the same answer. A box carries none, because there is nothing it was reduced from. @@ -465,7 +465,7 @@ def suggest_region(workspace: WorkspaceDep, body: SuggestRequest) -> SuggestionO connection_id=body.connection_id, prompt=prompt, allowed=tuple(body.allowed_geometries), - detail=body.detail, + tolerance=body.tolerance, ) return SuggestionOut( model_ref=answer.model_ref, @@ -474,7 +474,7 @@ def suggest_region(workspace: WorkspaceDep, body: SuggestRequest) -> SuggestionO SuggestedRegion(geometry=shape.geometry, contour=list(shape.contour)) for shape in answer.shapes ], - applied=AppliedParameters(detail=body.detail), + applied=AppliedParameters(tolerance=body.tolerance), parameters=list(answer.parameters), ) diff --git a/tests/server/test_suggest.py b/tests/server/test_suggest.py index e5b70e17..a146582d 100644 --- a/tests/server/test_suggest.py +++ b/tests/server/test_suggest.py @@ -177,15 +177,15 @@ def block(x0: int, y0: int, x1: int, y1: int) -> list[list[bool]]: ] -#: Big enough that the half-pixel tolerance floor is not what decides the vertex -#: count. On the ordinary fixture asset every `detail` step lands on the floor +#: Big enough that the quarter-pixel tolerance floor is not what decides the vertex +#: count. On the ordinary fixture asset every tolerance step lands on the floor #: and answers 20 vertices, which reports a working control and a dead one #: identically. ROOMY = (200, 200) def disc(radius: int = 70, frame: tuple[int, int] = ROOMY) -> list[list[bool]]: - """A filled circle — a shape whose vertex count actually moves with `detail`.""" + """A filled circle — a shape whose vertex count actually moves with tolerance.""" width, height = frame cx, cy = width // 2, height // 2 return [ @@ -476,7 +476,7 @@ def test_a_click_comes_back_as_a_polygon_with_its_confidence_and_model( assert body["confidence"] == pytest.approx(0.82) (region,) = body["regions"] assert region["geometry"]["type"] == "polygon" - assert region["geometry"]["points"] == [[2.0, 3.0], [12.0, 3.0], [12.0, 9.0], [2.0, 9.0]] + assert region["geometry"]["points"] == [[2.25, 3.0], [12.75, 3.0], [13.0, 9.75], [2.25, 10.0]] def test_a_tested_http_connection_suggests_through_its_endpoint( @@ -614,7 +614,7 @@ def test_a_request_that_sends_no_parameters_gets_the_defaults_back( body = ask(client, project=project, asset=asset, connection=connection).json() - assert body["applied"] == {"detail": "balanced"} + assert body["applied"] == {"tolerance": 1.0} def test_the_answer_echoes_the_parameters_it_was_given( @@ -628,10 +628,10 @@ def test_the_answer_echoes_the_parameters_it_was_given( project=project, asset=asset, connection=connection, - detail="fine", + tolerance=4.0, ).json() - assert body["applied"] == {"detail": "fine"} + assert body["applied"] == {"tolerance": 4.0} def test_a_polygon_class_is_told_the_one_parameter_applies( @@ -642,7 +642,7 @@ def test_a_polygon_class_is_told_the_one_parameter_applies( body = ask(client, project=project, asset=asset, connection=connection).json() - assert body["parameters"] == ["detail"] + assert body["parameters"] == ["tolerance"] def test_a_box_class_is_told_nothing_applies( @@ -651,7 +651,7 @@ def test_a_box_class_is_told_nothing_applies( """What the editor renders on a box class, and the whole of why it renders it. A client works none of this out: an empty list is what tells it to render no - adjustments at all. Remove `detail` from the polygon row of + adjustments at all. Remove `tolerance` from the polygon row of `PARAMETER_APPLIES_TO` and the assertion above goes red; declare it for a box and this one does. """ @@ -692,14 +692,14 @@ def test_an_answer_with_nothing_in_it_still_carries_its_controls( def test_a_polygon_carries_the_contour_it_was_reduced_from( client: TestClient, runner: InlineDispatcher, project: str, tmp_path: Path, answering: list[Any] ) -> None: - """What lets the editor re-run `detail` locally instead of asking again.""" + """What lets the editor re-run the tolerance locally instead of asking again.""" connection = a_connection(client) asset = an_asset(client, runner, project, tmp_path) (region,) = ask(client, project=project, asset=asset, connection=connection).json()["regions"] assert len(region["contour"]) >= len(region["geometry"]["points"]) - assert region["contour"][0] == [2.0, 3.0], "the traced boundary, in the asset's own pixels" + assert region["contour"][0] == [2.25, 3.0], "the traced boundary, in the asset's own pixels" def test_a_coarser_setting_comes_back_with_no_more_vertices( @@ -709,7 +709,7 @@ def test_a_coarser_setting_comes_back_with_no_more_vertices( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """`detail` reaching the shape at all, over HTTP. + """The tolerance reaching the shape at all, over HTTP. A disc rather than the rectangle the other tests use: a rectangle is four corners at every setting, so it would report a working control and a dead one @@ -721,11 +721,11 @@ def test_a_coarser_setting_comes_back_with_no_more_vertices( counts = [ len( - ask(client, project=project, asset=asset, connection=connection, detail=step).json()[ + ask(client, project=project, asset=asset, connection=connection, tolerance=t).json()[ "regions" ][0]["geometry"]["points"] ) - for step in ("coarse", "balanced", "fine") + for t in (8.0, 2.0, 0.5) ] assert counts[0] < counts[1] < counts[2], counts @@ -784,16 +784,32 @@ def test_a_setting_the_request_no_longer_takes_is_refused( ) -def test_a_detail_step_the_vocabulary_does_not_have_is_refused( +def test_a_tolerance_outside_the_range_is_refused_rather_than_clamped( client: TestClient, runner: InlineDispatcher, project: str, tmp_path: Path, answering: list[Any] ) -> None: - """A closed vocabulary, refused by the schema rather than silently defaulted.""" connection = a_connection(client) asset = an_asset(client, runner, project, tmp_path) - answer = ask(client, project=project, asset=asset, connection=connection, detail="sharpest") + assert ( + ask(client, project=project, asset=asset, connection=connection, tolerance=0.1).status_code + == 422 + ) + assert ( + ask(client, project=project, asset=asset, connection=connection, tolerance=17).status_code + == 422 + ) - assert answer.status_code == 422 + +def test_the_retired_setting_is_an_unknown_field( + client: TestClient, runner: InlineDispatcher, project: str, tmp_path: Path, answering: list[Any] +) -> None: + connection = a_connection(client) + asset = an_asset(client, runner, project, tmp_path) + + assert ( + ask(client, project=project, asset=asset, connection=connection, detail="fine").status_code + == 422 + ) # --- when the machine cannot carry it ---------------------------------------- From c5c85741ee704ef2014691baf11c9ff7030a92ec Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Tue, 25 Aug 2026 07:30:41 -0700 Subject: [PATCH 3/7] test(inference): the simplification fixture walks the tolerance ladder Golden contours are now sub-pixel and each case carries the polygon at every stop from 0.25 to 16 px; the refuse case is an empty mask. --- scripts/export_simplification_fixtures.py | 47 +- tests/fixtures/simplification.json | 7121 ++++++++++++++--- .../inference/test_simplification_fixture.py | 41 +- 3 files changed, 6217 insertions(+), 992 deletions(-) diff --git a/scripts/export_simplification_fixtures.py b/scripts/export_simplification_fixtures.py index 45545a80..ea84cd34 100644 --- a/scripts/export_simplification_fixtures.py +++ b/scripts/export_simplification_fixtures.py @@ -1,7 +1,7 @@ """Export simplification golden cases to tests/fixtures/simplification.json. -The editor re-runs `detail` locally so that moving it costs no round trip, while -the kernel stays authoritative on what is finally written. Those are two +The editor re-runs the tolerance locally so that moving it costs no round trip, +while the kernel stays authoritative on what is finally written. Those are two implementations of one algorithm, and the only thing that makes "they agree" a fact rather than a hope is a set of inputs both are held to. @@ -16,10 +16,10 @@ implementations meet: everything before it needs a segmenter's output and runs only here, and everything after it has to give the same answer in both places. The contours are produced by the real pipeline from shapes chosen to exercise -what can differ — a curve whose vertex count actually moves between steps, a +what can differ — a curve whose vertex count actually moves with the tolerance, a rectangle whose closing artifact has to be dropped identically, a shape small -enough that the tolerance floor decides instead of the ratio, and one too thin -to be a polygon at all. +enough that the floor keeps nearly all of it, and one too thin to be a polygon +at all. Usage: uv run python scripts/export_simplification_fixtures.py """ @@ -31,18 +31,15 @@ from pathlib import Path from typing import Any -from visionset.inference.masks import ( - EPSILON, - MINIMUM_TOLERANCE, - contour, - polygon_at, - tolerance_for, -) -from visionset.kernel.domain import Detail +from visionset.inference.masks import contour, polygon_at +from visionset.kernel.domain import DEFAULT_TOLERANCE, MAXIMUM_TOLERANCE, MINIMUM_TOLERANCE REPO_ROOT = Path(__file__).resolve().parent.parent OUTPUT_PATH = "tests/fixtures/simplification.json" +TOLERANCES: list[float] = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0] +"""The doubling ladder from the floor to the ceiling — every stop the brackets reach.""" + def disc(radius: int) -> list[list[bool]]: width = height = 2 * radius + 8 @@ -75,42 +72,40 @@ def blob(radius: int, lobes: int) -> list[list[bool]]: CASES: list[tuple[str, list[list[bool]]]] = [ # A curve: every step keeps a different number of vertices, so a simplifier - # that ignored `detail` entirely would be caught here and nowhere else. + # that ignored the tolerance entirely would be caught here and nowhere else. ("disc", disc(60)), ("blob", blob(70, 5)), # Straight edges: the ring is cut open at an arbitrary pixel and the closing # artifact has to be dropped identically, or one side answers five corners. ("rectangle", rect(10, 10, 60, 60)), ("thin-rectangle", rect(10, 10, 80, 14)), - # Small enough that the half-pixel floor decides rather than the ratio, which - # is a different branch of `tolerance_for` and the one a port most easily - # leaves out. + # Small enough that the floor keeps most of the ring: the case where the + # finest setting and the contour nearly coincide. ("tiny-disc", disc(4)), - # Two points: below what a polygon can be, and both sides must refuse. - ("two-pixels", rect(10, 10, 11, 10)), + # An empty mask: no contour at all, and both sides must refuse. + ("nothing", [[False] * 20 for _ in range(20)]), ] def build_fixture() -> dict[str, Any]: return { "minimum_tolerance": MINIMUM_TOLERANCE, - "epsilon": {step.value: EPSILON[step] for step in Detail}, + "default_tolerance": DEFAULT_TOLERANCE, + "maximum_tolerance": MAXIMUM_TOLERANCE, + "tolerances": TOLERANCES, "cases": [ { "name": name, "contour": [list(point) for point in contour(mask)], - "tolerance": { - step.value: tolerance_for(contour(mask), detail=step) for step in Detail - }, - "polygon": {step.value: _points(mask, step) for step in Detail}, + "polygon": {str(t): _points(mask, t) for t in TOLERANCES}, } for name, mask in CASES ], } -def _points(mask: list[list[bool]], step: Detail) -> list[list[float]] | None: - polygon = polygon_at(contour(mask), detail=step) +def _points(mask: list[list[bool]], tolerance: float) -> list[list[float]] | None: + polygon = polygon_at(contour(mask), tolerance=tolerance) return None if polygon is None else [list(point) for point in polygon.points] diff --git a/tests/fixtures/simplification.json b/tests/fixtures/simplification.json index 0c3e9edb..5074ae16 100644 --- a/tests/fixtures/simplification.json +++ b/tests/fixtures/simplification.json @@ -3,1713 +3,6946 @@ { "contour": [ [ - 64.0, + 64.25, 4.0 ], [ - 65.0, + 64.75, + 4.0 + ], + [ + 65.25, 5.0 ], [ - 74.0, + 74.75, 5.0 ], [ - 75.0, + 75.25, 6.0 ], [ - 79.0, + 79.75, 6.0 ], [ - 80.0, + 80.25, + 7.0 + ], + [ + 82.75, 7.0 ], [ - 85.0, + 83.25, 8.0 ], [ - 88.0, + 85.75, + 8.0 + ], + [ + 86.25, + 9.0 + ], + [ + 87.75, + 9.0 + ], + [ + 88.25, 10.0 ], [ - 90.0, + 90.75, 10.0 ], [ - 94.0, + 91.25, + 11.0 + ], + [ + 92.75, + 11.0 + ], + [ + 94.25, 13.0 ], [ - 97.0, + 95.75, + 13.0 + ], + [ + 96.25, 14.0 ], [ - 99.0, + 97.75, + 14.0 + ], + [ + 99.25, 16.0 ], [ - 100.0, + 100.75, 16.0 ], [ - 112.0, - 28.0 + 101.0, + 16.75 + ], + [ + 101.75, + 17.0 + ], + [ + 102.0, + 17.75 + ], + [ + 102.75, + 18.0 + ], + [ + 103.0, + 18.75 + ], + [ + 103.75, + 19.0 + ], + [ + 104.0, + 19.75 + ], + [ + 104.75, + 20.0 + ], + [ + 105.0, + 20.75 + ], + [ + 105.75, + 21.0 + ], + [ + 106.0, + 21.75 + ], + [ + 106.75, + 22.0 + ], + [ + 107.0, + 22.75 + ], + [ + 107.75, + 23.0 + ], + [ + 108.0, + 23.75 + ], + [ + 108.75, + 24.0 + ], + [ + 109.0, + 24.75 + ], + [ + 109.75, + 25.0 + ], + [ + 110.0, + 25.75 + ], + [ + 110.75, + 26.0 + ], + [ + 111.0, + 26.75 + ], + [ + 113.0, + 28.25 + ], + [ + 113.0, + 29.75 ], [ 115.0, - 34.0 + 31.25 + ], + [ + 115.0, + 32.75 + ], + [ + 116.0, + 33.25 + ], + [ + 116.0, + 34.75 ], [ - 117.0, - 36.0 + 118.0, + 36.25 ], [ 118.0, - 40.0 + 37.75 + ], + [ + 119.0, + 38.25 + ], + [ + 119.0, + 40.75 + ], + [ + 120.0, + 41.25 ], [ 120.0, - 43.0 + 42.75 + ], + [ + 121.0, + 43.25 ], [ 121.0, - 48.0 + 45.75 ], [ 122.0, - 49.0 + 46.25 ], [ 122.0, - 53.0 + 48.75 ], [ 123.0, - 54.0 + 49.25 ], [ 123.0, - 63.0 + 53.75 ], [ 124.0, + 54.25 + ], + [ + 124.0, + 63.75 + ], + [ + 124.75, 64.0 ], + [ + 125.0, + 64.75 + ], + [ + 124.0, + 65.25 + ], + [ + 124.0, + 74.75 + ], [ 123.0, - 65.0 + 75.25 ], [ 123.0, - 74.0 + 79.75 ], [ 122.0, - 75.0 + 80.25 ], [ 122.0, - 79.0 + 82.75 ], [ 121.0, - 80.0 + 83.25 + ], + [ + 121.0, + 85.75 ], [ 120.0, - 85.0 + 86.25 + ], + [ + 120.0, + 87.75 + ], + [ + 119.0, + 88.25 + ], + [ + 119.0, + 90.75 ], [ 118.0, - 88.0 + 91.25 ], [ 118.0, - 90.0 + 92.75 + ], + [ + 116.0, + 94.25 + ], + [ + 116.0, + 95.75 ], [ 115.0, - 94.0 + 96.25 ], [ - 112.0, - 100.0 + 115.0, + 97.75 ], [ - 100.0, - 112.0 + 113.0, + 99.25 ], [ - 94.0, - 115.0 + 113.0, + 100.75 ], [ - 92.0, - 117.0 + 112.25, + 101.0 ], [ - 88.0, - 118.0 + 112.0, + 101.75 ], [ - 85.0, - 120.0 + 111.25, + 102.0 ], [ - 80.0, - 121.0 + 111.0, + 102.75 ], [ - 79.0, - 122.0 + 110.25, + 103.0 ], [ - 75.0, - 122.0 + 110.0, + 103.75 ], [ - 74.0, - 123.0 + 109.25, + 104.0 ], [ - 65.0, - 123.0 + 109.0, + 104.75 ], [ - 64.0, - 124.0 + 108.25, + 105.0 ], [ - 63.0, - 123.0 + 108.0, + 105.75 ], [ - 54.0, - 123.0 + 107.25, + 106.0 ], [ - 53.0, - 122.0 + 107.0, + 106.75 ], [ - 49.0, - 122.0 + 106.25, + 107.0 ], [ - 48.0, - 121.0 + 106.0, + 107.75 ], [ - 43.0, - 120.0 + 105.25, + 108.0 ], [ - 40.0, - 118.0 + 105.0, + 108.75 ], [ - 38.0, - 118.0 + 104.25, + 109.0 ], [ - 34.0, - 115.0 + 104.0, + 109.75 ], [ - 31.0, - 114.0 + 103.25, + 110.0 ], [ - 29.0, - 112.0 + 103.0, + 110.75 ], [ - 28.0, - 112.0 + 102.25, + 111.0 ], [ - 16.0, - 100.0 + 100.75, + 113.0 ], [ - 13.0, - 94.0 + 99.25, + 113.0 ], [ - 11.0, - 92.0 + 97.75, + 115.0 ], [ - 10.0, - 88.0 + 96.25, + 115.0 ], [ - 8.0, - 85.0 + 95.75, + 116.0 ], [ - 7.0, - 80.0 + 94.25, + 116.0 ], [ - 6.0, - 79.0 + 92.75, + 118.0 ], [ - 6.0, - 75.0 + 91.25, + 118.0 ], [ - 5.0, - 74.0 + 90.75, + 119.0 ], [ - 5.0, - 65.0 + 88.25, + 119.0 ], [ - 4.0, - 64.0 + 87.75, + 120.0 ], [ - 5.0, - 63.0 + 86.25, + 120.0 ], [ - 5.0, - 54.0 + 85.75, + 121.0 ], [ - 6.0, - 53.0 + 83.25, + 121.0 ], [ - 6.0, - 49.0 + 82.75, + 122.0 ], [ - 7.0, - 48.0 + 80.25, + 122.0 ], [ - 7.0, - 46.0 + 79.75, + 123.0 ], [ - 8.0, - 45.0 + 75.25, + 123.0 ], [ - 8.0, - 43.0 + 74.75, + 124.0 ], [ - 10.0, - 40.0 + 65.25, + 124.0 ], [ - 11.0, - 36.0 + 65.0, + 124.75 ], [ - 13.0, - 34.0 + 64.25, + 125.0 ], [ - 16.0, - 28.0 + 63.75, + 124.0 ], [ - 28.0, - 16.0 + 54.25, + 124.0 ], [ - 34.0, - 13.0 + 53.75, + 123.0 ], [ - 36.0, - 11.0 + 49.25, + 123.0 ], [ - 40.0, - 10.0 + 48.75, + 122.0 ], [ - 43.0, - 8.0 + 46.25, + 122.0 ], [ - 48.0, - 7.0 + 45.75, + 121.0 ], [ - 49.0, - 6.0 + 43.25, + 121.0 ], [ - 53.0, - 6.0 + 42.75, + 120.0 ], [ - 54.0, - 5.0 + 41.25, + 120.0 ], [ - 63.0, - 5.0 - ] - ], - "name": "disc", - "polygon": { - "balanced": [ - [ - 64.0, - 4.0 - ], - [ - 79.0, - 6.0 - ], - [ - 97.0, - 14.0 - ], - [ - 117.0, - 36.0 - ], - [ - 122.0, - 49.0 - ], - [ - 124.0, - 64.0 - ], - [ - 122.0, - 79.0 - ], - [ - 112.0, - 100.0 - ], - [ - 92.0, - 117.0 - ], - [ - 79.0, - 122.0 - ], - [ - 64.0, - 124.0 - ], - [ - 49.0, - 122.0 - ], - [ - 31.0, - 114.0 - ], - [ - 11.0, - 92.0 - ], - [ - 6.0, - 79.0 - ], - [ - 4.0, - 64.0 - ], - [ - 6.0, - 49.0 - ], - [ - 11.0, - 36.0 - ], - [ - 16.0, - 28.0 - ], - [ - 36.0, - 11.0 - ], - [ - 49.0, - 6.0 - ] - ], - "coarse": [ - [ - 64.0, - 4.0 - ], - [ - 97.0, - 14.0 - ], - [ - 117.0, - 36.0 - ], - [ - 124.0, - 64.0 - ], - [ - 112.0, - 100.0 - ], - [ - 92.0, - 117.0 - ], - [ - 64.0, - 124.0 - ], - [ - 31.0, - 114.0 - ], - [ - 11.0, - 92.0 - ], - [ - 4.0, - 64.0 - ], - [ - 11.0, - 36.0 - ], - [ - 36.0, - 11.0 - ] + 40.75, + 119.0 ], - "fine": [ - [ - 64.0, - 4.0 - ], - [ - 65.0, - 5.0 - ], - [ - 79.0, - 6.0 - ], - [ - 88.0, - 10.0 - ], - [ - 90.0, - 10.0 - ], - [ - 97.0, - 14.0 - ], - [ - 112.0, - 28.0 - ], - [ - 117.0, - 36.0 - ], - [ - 122.0, - 49.0 - ], - [ - 123.0, - 63.0 - ], - [ - 124.0, - 64.0 - ], - [ - 123.0, - 65.0 - ], - [ - 122.0, - 79.0 - ], - [ - 118.0, - 88.0 - ], - [ - 118.0, - 90.0 - ], - [ - 112.0, - 100.0 - ], - [ - 100.0, - 112.0 - ], - [ - 92.0, - 117.0 - ], - [ - 79.0, - 122.0 - ], - [ - 65.0, - 123.0 - ], - [ - 64.0, - 124.0 - ], - [ - 63.0, - 123.0 - ], - [ - 49.0, - 122.0 - ], - [ - 40.0, - 118.0 - ], - [ - 38.0, - 118.0 - ], - [ - 31.0, - 114.0 - ], - [ - 16.0, - 100.0 - ], - [ - 11.0, - 92.0 - ], - [ - 6.0, - 79.0 - ], - [ - 5.0, - 65.0 - ], - [ - 4.0, - 64.0 - ], - [ - 5.0, - 63.0 - ], - [ - 6.0, - 49.0 - ], - [ - 11.0, - 36.0 - ], - [ - 16.0, - 28.0 - ], - [ - 28.0, - 16.0 - ], - [ - 36.0, - 11.0 - ], - [ - 49.0, - 6.0 - ], - [ - 63.0, - 5.0 - ] - ] - }, - "tolerance": { - "balanced": 1.6970562748477143, - "coarse": 4.242640687119286, - "fine": 0.6788225099390857 - } - }, - { - "contour": [ [ - 36.0, - 6.0 + 38.25, + 119.0 ], [ - 43.0, - 6.0 + 37.75, + 118.0 ], [ - 44.0, - 7.0 + 36.25, + 118.0 ], [ - 47.0, - 7.0 + 34.75, + 116.0 ], [ - 48.0, - 8.0 + 33.25, + 116.0 ], [ - 53.0, - 9.0 + 32.75, + 115.0 ], [ - 73.0, - 19.0 + 31.25, + 115.0 ], [ - 79.0, - 19.0 + 29.75, + 113.0 ], [ - 80.0, - 18.0 + 28.25, + 113.0 ], [ - 82.0, - 18.0 + 28.0, + 112.25 ], [ - 99.0, - 9.0 + 27.25, + 112.0 ], [ - 101.0, - 9.0 + 27.0, + 111.25 ], [ - 105.0, - 7.0 + 26.25, + 111.0 ], [ - 108.0, - 7.0 + 26.0, + 110.25 ], [ - 109.0, - 6.0 + 25.25, + 110.0 ], [ - 116.0, - 6.0 + 25.0, + 109.25 ], [ - 117.0, - 7.0 + 24.25, + 109.0 ], [ - 120.0, - 7.0 + 24.0, + 108.25 ], [ - 124.0, - 9.0 + 23.25, + 108.0 ], [ - 129.0, - 14.0 + 23.0, + 107.25 ], [ - 131.0, - 18.0 + 22.25, + 107.0 ], [ - 131.0, - 20.0 + 22.0, + 106.25 ], [ - 132.0, - 21.0 + 21.25, + 106.0 ], [ - 132.0, - 39.0 + 21.0, + 105.25 ], [ - 131.0, - 40.0 + 20.25, + 105.0 ], [ - 131.0, - 45.0 + 20.0, + 104.25 ], [ - 130.0, - 46.0 + 19.25, + 104.0 ], [ - 130.0, - 51.0 + 19.0, + 103.25 ], [ - 129.0, - 52.0 + 18.25, + 103.0 ], [ - 130.0, - 59.0 + 18.0, + 102.25 ], [ - 134.0, - 64.0 + 16.0, + 100.75 ], [ - 134.0, - 65.0 + 16.0, + 99.25 ], [ - 146.0, - 76.0 + 14.0, + 97.75 ], [ - 146.0, - 77.0 + 14.0, + 96.25 ], [ - 150.0, - 81.0 + 13.0, + 95.75 ], [ - 151.0, - 83.0 + 13.0, + 94.25 ], [ - 151.0, - 107.0 + 11.0, + 92.75 ], [ - 146.0, - 112.0 + 11.0, + 91.25 ], [ - 140.0, - 115.0 + 10.0, + 90.75 ], [ - 134.0, - 116.0 + 10.0, + 88.25 ], [ - 133.0, - 117.0 + 9.0, + 87.75 ], [ - 128.0, - 117.0 + 9.0, + 86.25 ], [ - 127.0, - 118.0 + 8.0, + 85.75 ], [ - 121.0, - 118.0 + 8.0, + 83.25 ], [ - 120.0, - 119.0 + 7.0, + 82.75 ], [ - 116.0, - 119.0 + 7.0, + 80.25 ], [ - 108.0, - 123.0 + 6.0, + 79.75 ], [ - 103.0, - 130.0 + 6.0, + 75.25 ], [ - 102.0, - 134.0 + 5.0, + 74.75 ], [ - 99.0, - 140.0 + 5.0, + 65.25 ], [ - 97.0, - 142.0 + 4.0, + 64.75 ], [ - 96.0, - 145.0 + 4.0, + 64.25 ], [ - 91.0, - 151.0 + 5.0, + 63.75 ], [ - 61.0, - 151.0 + 5.0, + 54.25 ], [ - 56.0, - 145.0 + 6.0, + 53.75 ], [ - 55.0, - 142.0 + 6.0, + 49.25 ], [ - 53.0, - 140.0 + 7.0, + 48.75 ], [ - 50.0, - 134.0 + 7.0, + 46.25 ], [ - 49.0, - 130.0 + 8.0, + 45.75 ], [ - 44.0, - 123.0 + 8.0, + 43.25 ], [ - 36.0, - 119.0 + 9.0, + 42.75 ], [ - 32.0, - 119.0 + 9.0, + 41.25 ], [ - 31.0, - 118.0 + 10.0, + 40.75 ], [ - 25.0, - 118.0 + 10.0, + 38.25 ], [ - 24.0, - 117.0 + 11.0, + 37.75 ], [ - 19.0, - 117.0 + 11.0, + 36.25 ], [ - 18.0, - 116.0 + 13.0, + 34.75 ], [ - 12.0, - 115.0 + 13.0, + 33.25 ], [ - 6.0, - 112.0 + 14.0, + 32.75 ], [ - 0.0, - 106.0 + 14.0, + 31.25 ], [ - 0.0, - 85.0 + 16.0, + 29.75 ], [ - 2.0, - 81.0 + 16.0, + 28.25 ], [ - 6.0, - 77.0 + 16.75, + 28.0 ], [ - 6.0, - 76.0 + 17.0, + 27.25 ], [ - 18.0, - 65.0 + 17.75, + 27.0 ], [ 18.0, - 64.0 + 26.25 ], [ - 22.0, - 59.0 + 18.75, + 26.0 ], [ - 23.0, - 52.0 + 19.0, + 25.25 ], [ - 22.0, - 51.0 + 19.75, + 25.0 ], [ - 22.0, - 46.0 + 20.0, + 24.25 ], [ - 21.0, - 45.0 + 20.75, + 24.0 ], [ 21.0, - 40.0 + 23.25 ], [ - 20.0, - 39.0 + 21.75, + 23.0 ], [ - 20.0, + 22.0, + 22.25 + ], + [ + 22.75, + 22.0 + ], + [ + 23.0, + 21.25 + ], + [ + 23.75, 21.0 ], [ - 21.0, + 24.0, + 20.25 + ], + [ + 24.75, 20.0 ], [ - 21.0, + 25.0, + 19.25 + ], + [ + 25.75, + 19.0 + ], + [ + 26.0, + 18.25 + ], + [ + 26.75, 18.0 ], [ - 23.0, + 28.25, + 16.0 + ], + [ + 29.75, + 16.0 + ], + [ + 31.25, 14.0 ], [ - 28.0, + 32.75, + 14.0 + ], + [ + 33.25, + 13.0 + ], + [ + 34.75, + 13.0 + ], + [ + 36.25, + 11.0 + ], + [ + 37.75, + 11.0 + ], + [ + 38.25, + 10.0 + ], + [ + 40.75, + 10.0 + ], + [ + 41.25, + 9.0 + ], + [ + 42.75, 9.0 ], [ - 32.0, + 43.25, + 8.0 + ], + [ + 45.75, + 8.0 + ], + [ + 46.25, 7.0 ], [ - 35.0, + 48.75, 7.0 + ], + [ + 49.25, + 6.0 + ], + [ + 53.75, + 6.0 + ], + [ + 54.25, + 5.0 + ], + [ + 63.75, + 5.0 + ], + [ + 64.0, + 4.25 ] ], - "name": "blob", + "name": "disc", "polygon": { - "balanced": [ + "0.25": [ + [ + 64.25, + 4.0 + ], + [ + 64.75, + 4.0 + ], + [ + 65.25, + 5.0 + ], + [ + 74.75, + 5.0 + ], + [ + 75.25, + 6.0 + ], + [ + 79.75, + 6.0 + ], + [ + 80.25, + 7.0 + ], + [ + 82.75, + 7.0 + ], + [ + 83.25, + 8.0 + ], + [ + 85.75, + 8.0 + ], + [ + 86.25, + 9.0 + ], + [ + 87.75, + 9.0 + ], + [ + 88.25, + 10.0 + ], + [ + 90.75, + 10.0 + ], + [ + 91.25, + 11.0 + ], + [ + 92.75, + 11.0 + ], + [ + 94.25, + 13.0 + ], + [ + 95.75, + 13.0 + ], + [ + 96.25, + 14.0 + ], + [ + 97.75, + 14.0 + ], + [ + 99.25, + 16.0 + ], + [ + 100.75, + 16.0 + ], + [ + 101.0, + 16.75 + ], + [ + 101.75, + 17.0 + ], + [ + 102.0, + 17.75 + ], + [ + 102.75, + 18.0 + ], + [ + 103.0, + 18.75 + ], + [ + 103.75, + 19.0 + ], + [ + 104.0, + 19.75 + ], + [ + 104.75, + 20.0 + ], + [ + 105.0, + 20.75 + ], + [ + 105.75, + 21.0 + ], + [ + 106.0, + 21.75 + ], + [ + 106.75, + 22.0 + ], + [ + 107.0, + 22.75 + ], + [ + 107.75, + 23.0 + ], + [ + 108.0, + 23.75 + ], + [ + 108.75, + 24.0 + ], + [ + 109.0, + 24.75 + ], + [ + 109.75, + 25.0 + ], + [ + 110.0, + 25.75 + ], + [ + 110.75, + 26.0 + ], + [ + 111.0, + 26.75 + ], + [ + 113.0, + 28.25 + ], + [ + 113.0, + 29.75 + ], + [ + 115.0, + 31.25 + ], + [ + 115.0, + 32.75 + ], + [ + 116.0, + 33.25 + ], + [ + 116.0, + 34.75 + ], + [ + 118.0, + 36.25 + ], + [ + 118.0, + 37.75 + ], + [ + 119.0, + 38.25 + ], + [ + 119.0, + 40.75 + ], + [ + 120.0, + 41.25 + ], + [ + 120.0, + 42.75 + ], + [ + 121.0, + 43.25 + ], + [ + 121.0, + 45.75 + ], + [ + 122.0, + 46.25 + ], + [ + 122.0, + 48.75 + ], + [ + 123.0, + 49.25 + ], + [ + 123.0, + 53.75 + ], + [ + 124.0, + 54.25 + ], + [ + 124.0, + 63.75 + ], + [ + 124.75, + 64.0 + ], + [ + 125.0, + 64.75 + ], + [ + 124.0, + 65.25 + ], + [ + 124.0, + 74.75 + ], + [ + 123.0, + 75.25 + ], + [ + 123.0, + 79.75 + ], + [ + 122.0, + 80.25 + ], + [ + 122.0, + 82.75 + ], + [ + 121.0, + 83.25 + ], + [ + 121.0, + 85.75 + ], + [ + 120.0, + 86.25 + ], + [ + 120.0, + 87.75 + ], + [ + 119.0, + 88.25 + ], + [ + 119.0, + 90.75 + ], + [ + 118.0, + 91.25 + ], + [ + 118.0, + 92.75 + ], + [ + 116.0, + 94.25 + ], + [ + 116.0, + 95.75 + ], + [ + 115.0, + 96.25 + ], + [ + 115.0, + 97.75 + ], + [ + 113.0, + 99.25 + ], + [ + 113.0, + 100.75 + ], + [ + 112.25, + 101.0 + ], + [ + 112.0, + 101.75 + ], + [ + 111.25, + 102.0 + ], + [ + 111.0, + 102.75 + ], + [ + 110.25, + 103.0 + ], + [ + 110.0, + 103.75 + ], + [ + 109.25, + 104.0 + ], + [ + 109.0, + 104.75 + ], + [ + 108.25, + 105.0 + ], + [ + 108.0, + 105.75 + ], + [ + 107.25, + 106.0 + ], + [ + 107.0, + 106.75 + ], + [ + 106.25, + 107.0 + ], + [ + 106.0, + 107.75 + ], + [ + 105.25, + 108.0 + ], + [ + 105.0, + 108.75 + ], + [ + 104.25, + 109.0 + ], + [ + 104.0, + 109.75 + ], + [ + 103.25, + 110.0 + ], + [ + 103.0, + 110.75 + ], + [ + 102.25, + 111.0 + ], + [ + 100.75, + 113.0 + ], + [ + 99.25, + 113.0 + ], + [ + 97.75, + 115.0 + ], + [ + 96.25, + 115.0 + ], + [ + 95.75, + 116.0 + ], + [ + 94.25, + 116.0 + ], + [ + 92.75, + 118.0 + ], + [ + 91.25, + 118.0 + ], + [ + 90.75, + 119.0 + ], + [ + 88.25, + 119.0 + ], + [ + 87.75, + 120.0 + ], + [ + 86.25, + 120.0 + ], + [ + 85.75, + 121.0 + ], + [ + 83.25, + 121.0 + ], + [ + 82.75, + 122.0 + ], + [ + 80.25, + 122.0 + ], + [ + 79.75, + 123.0 + ], + [ + 75.25, + 123.0 + ], + [ + 74.75, + 124.0 + ], + [ + 65.25, + 124.0 + ], + [ + 65.0, + 124.75 + ], + [ + 64.25, + 125.0 + ], + [ + 63.75, + 124.0 + ], + [ + 54.25, + 124.0 + ], + [ + 53.75, + 123.0 + ], + [ + 49.25, + 123.0 + ], + [ + 48.75, + 122.0 + ], + [ + 46.25, + 122.0 + ], + [ + 45.75, + 121.0 + ], + [ + 43.25, + 121.0 + ], + [ + 42.75, + 120.0 + ], + [ + 41.25, + 120.0 + ], + [ + 40.75, + 119.0 + ], + [ + 38.25, + 119.0 + ], + [ + 37.75, + 118.0 + ], + [ + 36.25, + 118.0 + ], + [ + 34.75, + 116.0 + ], + [ + 33.25, + 116.0 + ], + [ + 32.75, + 115.0 + ], + [ + 31.25, + 115.0 + ], + [ + 29.75, + 113.0 + ], + [ + 28.25, + 113.0 + ], + [ + 28.0, + 112.25 + ], + [ + 27.25, + 112.0 + ], + [ + 27.0, + 111.25 + ], + [ + 26.25, + 111.0 + ], + [ + 26.0, + 110.25 + ], + [ + 25.25, + 110.0 + ], + [ + 25.0, + 109.25 + ], + [ + 24.25, + 109.0 + ], + [ + 24.0, + 108.25 + ], + [ + 23.25, + 108.0 + ], + [ + 23.0, + 107.25 + ], + [ + 22.25, + 107.0 + ], + [ + 22.0, + 106.25 + ], + [ + 21.25, + 106.0 + ], + [ + 21.0, + 105.25 + ], + [ + 20.25, + 105.0 + ], + [ + 20.0, + 104.25 + ], + [ + 19.25, + 104.0 + ], + [ + 19.0, + 103.25 + ], + [ + 18.25, + 103.0 + ], + [ + 18.0, + 102.25 + ], + [ + 16.0, + 100.75 + ], + [ + 16.0, + 99.25 + ], + [ + 14.0, + 97.75 + ], + [ + 14.0, + 96.25 + ], + [ + 13.0, + 95.75 + ], + [ + 13.0, + 94.25 + ], + [ + 11.0, + 92.75 + ], + [ + 11.0, + 91.25 + ], + [ + 10.0, + 90.75 + ], + [ + 10.0, + 88.25 + ], + [ + 9.0, + 87.75 + ], + [ + 9.0, + 86.25 + ], + [ + 8.0, + 85.75 + ], + [ + 8.0, + 83.25 + ], + [ + 7.0, + 82.75 + ], + [ + 7.0, + 80.25 + ], + [ + 6.0, + 79.75 + ], + [ + 6.0, + 75.25 + ], + [ + 5.0, + 74.75 + ], + [ + 5.0, + 65.25 + ], + [ + 4.0, + 64.75 + ], + [ + 4.0, + 64.25 + ], + [ + 5.0, + 63.75 + ], + [ + 5.0, + 54.25 + ], + [ + 6.0, + 53.75 + ], + [ + 6.0, + 49.25 + ], + [ + 7.0, + 48.75 + ], + [ + 7.0, + 46.25 + ], + [ + 8.0, + 45.75 + ], + [ + 8.0, + 43.25 + ], + [ + 9.0, + 42.75 + ], + [ + 9.0, + 41.25 + ], + [ + 10.0, + 40.75 + ], + [ + 10.0, + 38.25 + ], + [ + 11.0, + 37.75 + ], + [ + 11.0, + 36.25 + ], + [ + 13.0, + 34.75 + ], + [ + 13.0, + 33.25 + ], + [ + 14.0, + 32.75 + ], + [ + 14.0, + 31.25 + ], + [ + 16.0, + 29.75 + ], + [ + 16.0, + 28.25 + ], + [ + 16.75, + 28.0 + ], + [ + 17.0, + 27.25 + ], + [ + 17.75, + 27.0 + ], + [ + 18.0, + 26.25 + ], + [ + 18.75, + 26.0 + ], + [ + 19.0, + 25.25 + ], + [ + 19.75, + 25.0 + ], + [ + 20.0, + 24.25 + ], + [ + 20.75, + 24.0 + ], + [ + 21.0, + 23.25 + ], + [ + 21.75, + 23.0 + ], + [ + 22.0, + 22.25 + ], + [ + 22.75, + 22.0 + ], + [ + 23.0, + 21.25 + ], + [ + 23.75, + 21.0 + ], + [ + 24.0, + 20.25 + ], + [ + 24.75, + 20.0 + ], + [ + 25.0, + 19.25 + ], + [ + 25.75, + 19.0 + ], + [ + 26.0, + 18.25 + ], + [ + 26.75, + 18.0 + ], + [ + 28.25, + 16.0 + ], + [ + 29.75, + 16.0 + ], + [ + 31.25, + 14.0 + ], + [ + 32.75, + 14.0 + ], + [ + 33.25, + 13.0 + ], + [ + 34.75, + 13.0 + ], + [ + 36.25, + 11.0 + ], + [ + 37.75, + 11.0 + ], + [ + 38.25, + 10.0 + ], + [ + 40.75, + 10.0 + ], + [ + 41.25, + 9.0 + ], + [ + 42.75, + 9.0 + ], + [ + 43.25, + 8.0 + ], + [ + 45.75, + 8.0 + ], + [ + 46.25, + 7.0 + ], + [ + 48.75, + 7.0 + ], + [ + 49.25, + 6.0 + ], + [ + 53.75, + 6.0 + ], + [ + 54.25, + 5.0 + ], + [ + 63.75, + 5.0 + ] + ], + "0.5": [ + [ + 64.25, + 4.0 + ], + [ + 65.25, + 5.0 + ], + [ + 74.75, + 5.0 + ], + [ + 75.25, + 6.0 + ], + [ + 79.75, + 6.0 + ], + [ + 80.25, + 7.0 + ], + [ + 82.75, + 7.0 + ], + [ + 83.25, + 8.0 + ], + [ + 85.75, + 8.0 + ], + [ + 88.25, + 10.0 + ], + [ + 90.75, + 10.0 + ], + [ + 91.25, + 11.0 + ], + [ + 92.75, + 11.0 + ], + [ + 94.25, + 13.0 + ], + [ + 95.75, + 13.0 + ], + [ + 96.25, + 14.0 + ], + [ + 97.75, + 14.0 + ], + [ + 99.25, + 16.0 + ], + [ + 100.75, + 16.0 + ], + [ + 113.0, + 28.25 + ], + [ + 113.0, + 29.75 + ], + [ + 115.0, + 31.25 + ], + [ + 116.0, + 34.75 + ], + [ + 118.0, + 36.25 + ], + [ + 118.0, + 37.75 + ], + [ + 119.0, + 38.25 + ], + [ + 119.0, + 40.75 + ], + [ + 120.0, + 41.25 + ], + [ + 120.0, + 42.75 + ], + [ + 121.0, + 43.25 + ], + [ + 121.0, + 45.75 + ], + [ + 122.0, + 46.25 + ], + [ + 122.0, + 48.75 + ], + [ + 123.0, + 49.25 + ], + [ + 123.0, + 53.75 + ], + [ + 124.0, + 54.25 + ], + [ + 124.0, + 63.75 + ], + [ + 125.0, + 64.75 + ], + [ + 124.0, + 65.25 + ], + [ + 124.0, + 74.75 + ], + [ + 123.0, + 75.25 + ], + [ + 123.0, + 79.75 + ], + [ + 122.0, + 80.25 + ], + [ + 121.0, + 85.75 + ], + [ + 119.0, + 88.25 + ], + [ + 119.0, + 90.75 + ], + [ + 118.0, + 91.25 + ], + [ + 118.0, + 92.75 + ], + [ + 116.0, + 94.25 + ], + [ + 116.0, + 95.75 + ], + [ + 115.0, + 96.25 + ], + [ + 115.0, + 97.75 + ], + [ + 113.0, + 99.25 + ], + [ + 113.0, + 100.75 + ], + [ + 100.75, + 113.0 + ], + [ + 99.25, + 113.0 + ], + [ + 97.75, + 115.0 + ], + [ + 94.25, + 116.0 + ], + [ + 92.75, + 118.0 + ], + [ + 91.25, + 118.0 + ], + [ + 90.75, + 119.0 + ], + [ + 88.25, + 119.0 + ], + [ + 85.75, + 121.0 + ], + [ + 83.25, + 121.0 + ], + [ + 82.75, + 122.0 + ], + [ + 80.25, + 122.0 + ], + [ + 79.75, + 123.0 + ], + [ + 75.25, + 123.0 + ], + [ + 74.75, + 124.0 + ], + [ + 65.25, + 124.0 + ], + [ + 64.25, + 125.0 + ], + [ + 63.75, + 124.0 + ], + [ + 54.25, + 124.0 + ], + [ + 53.75, + 123.0 + ], + [ + 49.25, + 123.0 + ], + [ + 48.75, + 122.0 + ], + [ + 43.25, + 121.0 + ], + [ + 40.75, + 119.0 + ], + [ + 38.25, + 119.0 + ], + [ + 37.75, + 118.0 + ], + [ + 36.25, + 118.0 + ], + [ + 34.75, + 116.0 + ], + [ + 31.25, + 115.0 + ], + [ + 29.75, + 113.0 + ], + [ + 28.25, + 113.0 + ], + [ + 16.0, + 100.75 + ], + [ + 16.0, + 99.25 + ], + [ + 14.0, + 97.75 + ], + [ + 13.0, + 94.25 + ], + [ + 11.0, + 92.75 + ], + [ + 11.0, + 91.25 + ], + [ + 10.0, + 90.75 + ], + [ + 10.0, + 88.25 + ], + [ + 9.0, + 87.75 + ], + [ + 9.0, + 86.25 + ], + [ + 8.0, + 85.75 + ], + [ + 8.0, + 83.25 + ], + [ + 7.0, + 82.75 + ], + [ + 7.0, + 80.25 + ], + [ + 6.0, + 79.75 + ], + [ + 6.0, + 75.25 + ], + [ + 5.0, + 74.75 + ], + [ + 5.0, + 65.25 + ], + [ + 4.0, + 64.75 + ], + [ + 5.0, + 63.75 + ], + [ + 5.0, + 54.25 + ], + [ + 6.0, + 53.75 + ], + [ + 6.0, + 49.25 + ], + [ + 7.0, + 48.75 + ], + [ + 7.0, + 46.25 + ], + [ + 8.0, + 45.75 + ], + [ + 8.0, + 43.25 + ], + [ + 10.0, + 40.75 + ], + [ + 10.0, + 38.25 + ], + [ + 11.0, + 37.75 + ], + [ + 11.0, + 36.25 + ], + [ + 13.0, + 34.75 + ], + [ + 13.0, + 33.25 + ], + [ + 14.0, + 32.75 + ], + [ + 14.0, + 31.25 + ], + [ + 16.0, + 29.75 + ], + [ + 16.0, + 28.25 + ], + [ + 28.25, + 16.0 + ], + [ + 29.75, + 16.0 + ], + [ + 31.25, + 14.0 + ], + [ + 34.75, + 13.0 + ], + [ + 36.25, + 11.0 + ], + [ + 37.75, + 11.0 + ], + [ + 38.25, + 10.0 + ], + [ + 40.75, + 10.0 + ], + [ + 43.25, + 8.0 + ], + [ + 45.75, + 8.0 + ], + [ + 46.25, + 7.0 + ], + [ + 48.75, + 7.0 + ], + [ + 49.25, + 6.0 + ], + [ + 53.75, + 6.0 + ], + [ + 54.25, + 5.0 + ], + [ + 63.75, + 5.0 + ] + ], + "1.0": [ + [ + 64.25, + 4.0 + ], + [ + 79.75, + 6.0 + ], + [ + 92.75, + 11.0 + ], + [ + 100.75, + 16.0 + ], + [ + 113.0, + 28.25 + ], + [ + 118.0, + 36.25 + ], + [ + 123.0, + 49.25 + ], + [ + 125.0, + 64.75 + ], + [ + 123.0, + 79.75 + ], + [ + 119.0, + 90.75 + ], + [ + 113.0, + 100.75 + ], + [ + 100.75, + 113.0 + ], + [ + 90.75, + 119.0 + ], + [ + 79.75, + 123.0 + ], + [ + 64.25, + 125.0 + ], + [ + 49.25, + 123.0 + ], + [ + 31.25, + 115.0 + ], + [ + 16.0, + 100.75 + ], + [ + 11.0, + 92.75 + ], + [ + 6.0, + 79.75 + ], + [ + 4.0, + 64.75 + ], + [ + 6.0, + 49.25 + ], + [ + 11.0, + 36.25 + ], + [ + 16.0, + 28.25 + ], + [ + 31.25, + 14.0 + ], + [ + 49.25, + 6.0 + ] + ], + "16.0": [ + [ + 64.25, + 4.0 + ], + [ + 118.0, + 36.25 + ], + [ + 113.0, + 100.75 + ], + [ + 64.25, + 125.0 + ], + [ + 11.0, + 92.75 + ], + [ + 11.0, + 36.25 + ] + ], + "2.0": [ + [ + 64.25, + 4.0 + ], + [ + 92.75, + 11.0 + ], + [ + 100.75, + 16.0 + ], + [ + 118.0, + 36.25 + ], + [ + 125.0, + 64.75 + ], + [ + 123.0, + 79.75 + ], + [ + 113.0, + 100.75 + ], + [ + 90.75, + 119.0 + ], + [ + 64.25, + 125.0 + ], + [ + 49.25, + 123.0 + ], + [ + 31.25, + 115.0 + ], + [ + 11.0, + 92.75 + ], + [ + 4.0, + 64.75 + ], + [ + 11.0, + 36.25 + ], + [ + 31.25, + 14.0 + ], + [ + 49.25, + 6.0 + ] + ], + "4.0": [ + [ + 64.25, + 4.0 + ], + [ + 92.75, + 11.0 + ], + [ + 118.0, + 36.25 + ], + [ + 125.0, + 64.75 + ], + [ + 113.0, + 100.75 + ], + [ + 90.75, + 119.0 + ], + [ + 64.25, + 125.0 + ], + [ + 31.25, + 115.0 + ], + [ + 11.0, + 92.75 + ], + [ + 4.0, + 64.75 + ], + [ + 11.0, + 36.25 + ], + [ + 31.25, + 14.0 + ] + ], + "8.0": [ + [ + 64.25, + 4.0 + ], + [ + 92.75, + 11.0 + ], + [ + 118.0, + 36.25 + ], + [ + 125.0, + 64.75 + ], + [ + 113.0, + 100.75 + ], + [ + 64.25, + 125.0 + ], + [ + 31.25, + 115.0 + ], + [ + 11.0, + 92.75 + ], + [ + 11.0, + 36.25 + ], + [ + 31.25, + 14.0 + ] + ] + } + }, + { + "contour": [ + [ + 36.25, + 6.0 + ], + [ + 43.75, + 6.0 + ], + [ + 44.25, + 7.0 + ], + [ + 47.75, + 7.0 + ], + [ + 48.25, + 8.0 + ], + [ + 50.75, + 8.0 + ], + [ + 51.25, + 9.0 + ], + [ + 53.75, + 9.0 + ], + [ + 54.25, + 10.0 + ], + [ + 55.75, + 10.0 + ], + [ + 56.25, + 11.0 + ], + [ + 57.75, + 11.0 + ], + [ + 58.25, + 12.0 + ], + [ + 59.75, + 12.0 + ], + [ + 60.25, + 13.0 + ], + [ + 61.75, + 13.0 + ], + [ + 62.25, + 14.0 + ], + [ + 63.75, + 14.0 + ], + [ + 64.25, + 15.0 + ], + [ + 65.75, + 15.0 + ], + [ + 66.25, + 16.0 + ], + [ + 67.75, + 16.0 + ], + [ + 68.25, + 17.0 + ], + [ + 69.75, + 17.0 + ], + [ + 70.25, + 18.0 + ], + [ + 72.75, + 18.0 + ], + [ + 73.25, + 19.0 + ], + [ + 79.75, + 19.0 + ], + [ + 80.25, + 18.0 + ], + [ + 82.75, + 18.0 + ], + [ + 83.25, + 17.0 + ], + [ + 84.75, + 17.0 + ], + [ + 85.25, + 16.0 + ], + [ + 86.75, + 16.0 + ], + [ + 87.25, + 15.0 + ], + [ + 88.75, + 15.0 + ], + [ + 89.25, + 14.0 + ], + [ + 90.75, + 14.0 + ], + [ + 91.25, + 13.0 + ], + [ + 92.75, + 13.0 + ], + [ + 93.25, + 12.0 + ], + [ + 94.75, + 12.0 + ], + [ + 95.25, + 11.0 + ], + [ + 96.75, + 11.0 + ], + [ + 97.25, + 10.0 + ], + [ + 98.75, + 10.0 + ], + [ + 99.25, + 9.0 + ], + [ + 101.75, + 9.0 + ], + [ + 102.25, + 8.0 + ], + [ + 104.75, + 8.0 + ], + [ + 105.25, + 7.0 + ], + [ + 108.75, + 7.0 + ], + [ + 109.25, + 6.0 + ], + [ + 116.75, + 6.0 + ], + [ + 117.25, + 7.0 + ], + [ + 120.75, + 7.0 + ], + [ + 121.25, + 8.0 + ], + [ + 122.75, + 8.0 + ], + [ + 123.25, + 9.0 + ], + [ + 124.75, + 9.0 + ], + [ + 125.0, + 9.75 + ], + [ + 125.75, + 10.0 + ], + [ + 126.0, + 10.75 + ], + [ + 126.75, + 11.0 + ], + [ + 127.0, + 11.75 + ], + [ + 127.75, + 12.0 + ], + [ + 128.0, + 12.75 + ], + [ + 130.0, + 14.25 + ], + [ + 130.0, + 15.75 + ], + [ + 131.0, + 16.25 + ], + [ + 131.0, + 17.75 + ], + [ + 132.0, + 18.25 + ], + [ + 132.0, + 20.75 + ], + [ + 133.0, + 21.25 + ], + [ + 133.0, + 39.75 + ], + [ + 132.0, + 40.25 + ], + [ + 132.0, + 45.75 + ], + [ + 131.0, + 46.25 + ], + [ + 131.0, + 51.75 + ], + [ + 130.0, + 52.25 + ], + [ + 130.0, + 55.75 + ], + [ + 131.0, + 56.25 + ], + [ + 131.0, + 59.75 + ], + [ + 132.0, + 60.25 + ], + [ + 132.0, + 61.75 + ], + [ + 132.75, + 62.0 + ], + [ + 133.0, + 62.75 + ], + [ + 135.0, + 64.25 + ], + [ + 135.0, + 65.75 + ], + [ + 135.75, + 66.0 + ], + [ + 136.0, + 66.75 + ], + [ + 136.75, + 67.0 + ], + [ + 137.0, + 67.75 + ], + [ + 137.75, + 68.0 + ], + [ + 139.25, + 70.0 + ], + [ + 140.75, + 70.0 + ], + [ + 141.0, + 70.75 + ], + [ + 141.75, + 71.0 + ], + [ + 142.0, + 71.75 + ], + [ + 142.75, + 72.0 + ], + [ + 143.0, + 72.75 + ], + [ + 143.75, + 73.0 + ], + [ + 144.0, + 73.75 + ], + [ + 144.75, + 74.0 + ], + [ + 145.0, + 74.75 + ], + [ + 147.0, + 76.25 + ], + [ + 147.0, + 77.75 + ], + [ + 147.75, + 78.0 + ], + [ + 148.0, + 78.75 + ], + [ + 148.75, + 79.0 + ], + [ + 149.0, + 79.75 + ], + [ + 151.0, + 81.25 + ], + [ + 151.0, + 82.75 + ], + [ + 152.0, + 83.25 + ], + [ + 152.0, + 107.75 + ], + [ + 151.25, + 108.0 + ], + [ + 151.0, + 108.75 + ], + [ + 150.25, + 109.0 + ], + [ + 150.0, + 109.75 + ], + [ + 149.25, + 110.0 + ], + [ + 149.0, + 110.75 + ], + [ + 148.25, + 111.0 + ], + [ + 146.75, + 113.0 + ], + [ + 145.25, + 113.0 + ], + [ + 144.75, + 114.0 + ], + [ + 143.25, + 114.0 + ], + [ + 142.75, + 115.0 + ], + [ + 141.25, + 115.0 + ], + [ + 140.75, + 116.0 + ], + [ + 138.25, + 116.0 + ], + [ + 137.75, + 117.0 + ], + [ + 134.25, + 117.0 + ], + [ + 133.75, + 118.0 + ], + [ + 128.25, + 118.0 + ], + [ + 127.75, + 119.0 + ], + [ + 121.25, + 119.0 + ], + [ + 120.75, + 120.0 + ], + [ + 116.25, + 120.0 + ], + [ + 115.75, + 121.0 + ], + [ + 113.25, + 121.0 + ], + [ + 112.75, + 122.0 + ], + [ + 111.25, + 122.0 + ], + [ + 109.75, + 124.0 + ], + [ + 108.25, + 124.0 + ], + [ + 108.0, + 125.75 + ], + [ + 106.0, + 127.25 + ], + [ + 106.0, + 128.75 + ], + [ + 104.0, + 130.25 + ], + [ + 104.0, + 131.75 + ], + [ + 103.0, + 132.25 + ], + [ + 103.0, + 134.75 + ], + [ + 102.0, + 135.25 + ], + [ + 102.0, + 136.75 + ], + [ + 101.0, + 137.25 + ], + [ + 101.0, + 138.75 + ], + [ + 100.0, + 139.25 + ], + [ + 100.0, + 140.75 + ], + [ + 98.0, + 142.25 + ], + [ + 98.0, + 143.75 + ], + [ + 97.0, + 144.25 + ], + [ + 97.0, + 145.75 + ], + [ + 95.0, + 147.25 + ], + [ + 95.0, + 148.75 + ], + [ + 94.25, + 149.0 + ], + [ + 94.0, + 149.75 + ], + [ + 93.25, + 150.0 + ], + [ + 91.75, + 152.0 + ], + [ + 61.25, + 152.0 + ], + [ + 61.0, + 151.25 + ], + [ + 60.25, + 151.0 + ], + [ + 60.0, + 150.25 + ], + [ + 58.0, + 148.75 + ], + [ + 58.0, + 147.25 + ], + [ + 56.0, + 145.75 + ], + [ + 56.0, + 144.25 + ], + [ + 55.0, + 143.75 + ], + [ + 55.0, + 142.25 + ], + [ + 53.0, + 140.75 + ], + [ + 53.0, + 139.25 + ], + [ + 52.0, + 138.75 + ], + [ + 52.0, + 137.25 + ], + [ + 51.0, + 136.75 + ], + [ + 51.0, + 135.25 + ], + [ + 50.0, + 134.75 + ], + [ + 50.0, + 132.25 + ], + [ + 49.0, + 131.75 + ], + [ + 49.0, + 130.25 + ], + [ + 47.0, + 128.75 + ], + [ + 47.0, + 127.25 + ], + [ + 45.0, + 125.75 + ], + [ + 44.75, + 124.0 + ], + [ + 43.25, + 124.0 + ], + [ + 41.75, + 122.0 + ], + [ + 40.25, + 122.0 + ], + [ + 39.75, + 121.0 + ], + [ + 37.25, + 121.0 + ], + [ + 36.75, + 120.0 + ], + [ + 32.25, + 120.0 + ], + [ + 31.75, + 119.0 + ], + [ + 25.25, + 119.0 + ], + [ + 24.75, + 118.0 + ], + [ + 19.25, + 118.0 + ], + [ + 18.75, + 117.0 + ], + [ + 15.25, + 117.0 + ], + [ + 14.75, + 116.0 + ], + [ + 12.25, + 116.0 + ], + [ + 11.75, + 115.0 + ], + [ + 10.25, + 115.0 + ], + [ + 9.75, + 114.0 + ], + [ + 8.25, + 114.0 + ], + [ + 7.75, + 113.0 + ], + [ + 6.25, + 113.0 + ], + [ + 6.0, + 112.25 + ], + [ + 5.25, + 112.0 + ], + [ + 5.0, + 111.25 + ], + [ + 4.25, + 111.0 + ], + [ + 4.0, + 110.25 + ], + [ + 3.25, + 110.0 + ], + [ + 3.0, + 109.25 + ], + [ + 2.25, + 109.0 + ], + [ + 2.0, + 108.25 + ], + [ + 0.0, + 106.75 + ], + [ + 0.0, + 85.25 + ], + [ + 1.0, + 84.75 + ], + [ + 1.0, + 83.25 + ], + [ + 2.0, + 82.75 + ], + [ + 2.0, + 81.25 + ], + [ + 2.75, + 81.0 + ], + [ + 3.0, + 80.25 + ], + [ + 3.75, + 80.0 + ], + [ + 4.0, + 79.25 + ], + [ + 6.0, + 77.75 + ], + [ + 6.0, + 76.25 + ], + [ + 6.75, + 76.0 + ], + [ + 7.0, + 75.25 + ], + [ + 7.75, + 75.0 + ], + [ + 8.0, + 74.25 + ], + [ + 8.75, + 74.0 + ], + [ + 9.0, + 73.25 + ], + [ + 9.75, + 73.0 + ], + [ + 10.0, + 72.25 + ], + [ + 10.75, + 72.0 + ], + [ + 12.25, + 70.0 + ], + [ + 13.75, + 70.0 + ], + [ + 14.0, + 69.25 + ], + [ + 14.75, + 69.0 + ], + [ + 15.0, + 68.25 + ], + [ + 15.75, + 68.0 + ], + [ + 16.0, + 67.25 + ], + [ + 18.0, + 65.75 + ], + [ + 18.0, + 64.25 + ], + [ + 18.75, + 64.0 + ], + [ + 19.0, + 63.25 + ], + [ + 21.0, + 61.75 + ], + [ + 21.0, + 60.25 + ], + [ + 22.0, + 59.75 + ], + [ + 22.0, + 56.25 + ], + [ + 23.0, + 55.75 + ], + [ + 23.0, + 52.25 + ], + [ + 22.0, + 51.75 + ], + [ + 22.0, + 46.25 + ], + [ + 21.0, + 45.75 + ], + [ + 21.0, + 40.25 + ], + [ + 20.0, + 39.75 + ], + [ + 20.0, + 21.25 + ], + [ + 21.0, + 20.75 + ], + [ + 21.0, + 18.25 + ], + [ + 22.0, + 17.75 + ], + [ + 22.0, + 16.25 + ], + [ + 23.0, + 15.75 + ], + [ + 23.0, + 14.25 + ], + [ + 23.75, + 14.0 + ], + [ + 24.0, + 13.25 + ], + [ + 24.75, + 13.0 + ], + [ + 25.0, + 12.25 + ], + [ + 25.75, + 12.0 + ], + [ + 26.0, + 11.25 + ], + [ + 26.75, + 11.0 + ], + [ + 28.25, + 9.0 + ], + [ + 29.75, + 9.0 + ], + [ + 30.25, + 8.0 + ], + [ + 31.75, + 8.0 + ], + [ + 32.25, + 7.0 + ], + [ + 35.75, + 7.0 + ], + [ + 36.0, + 6.25 + ] + ], + "name": "blob", + "polygon": { + "0.25": [ + [ + 36.25, + 6.0 + ], + [ + 43.75, + 6.0 + ], + [ + 44.25, + 7.0 + ], + [ + 47.75, + 7.0 + ], + [ + 48.25, + 8.0 + ], + [ + 50.75, + 8.0 + ], + [ + 51.25, + 9.0 + ], + [ + 53.75, + 9.0 + ], + [ + 54.25, + 10.0 + ], + [ + 55.75, + 10.0 + ], + [ + 56.25, + 11.0 + ], + [ + 57.75, + 11.0 + ], + [ + 58.25, + 12.0 + ], + [ + 59.75, + 12.0 + ], + [ + 60.25, + 13.0 + ], + [ + 61.75, + 13.0 + ], + [ + 62.25, + 14.0 + ], + [ + 63.75, + 14.0 + ], + [ + 64.25, + 15.0 + ], + [ + 65.75, + 15.0 + ], + [ + 66.25, + 16.0 + ], + [ + 67.75, + 16.0 + ], + [ + 68.25, + 17.0 + ], + [ + 69.75, + 17.0 + ], + [ + 70.25, + 18.0 + ], + [ + 72.75, + 18.0 + ], + [ + 73.25, + 19.0 + ], + [ + 79.75, + 19.0 + ], + [ + 80.25, + 18.0 + ], + [ + 82.75, + 18.0 + ], + [ + 83.25, + 17.0 + ], + [ + 84.75, + 17.0 + ], + [ + 85.25, + 16.0 + ], + [ + 86.75, + 16.0 + ], + [ + 87.25, + 15.0 + ], + [ + 88.75, + 15.0 + ], + [ + 89.25, + 14.0 + ], + [ + 90.75, + 14.0 + ], + [ + 91.25, + 13.0 + ], + [ + 92.75, + 13.0 + ], + [ + 93.25, + 12.0 + ], + [ + 94.75, + 12.0 + ], + [ + 95.25, + 11.0 + ], + [ + 96.75, + 11.0 + ], + [ + 97.25, + 10.0 + ], + [ + 98.75, + 10.0 + ], + [ + 99.25, + 9.0 + ], + [ + 101.75, + 9.0 + ], + [ + 102.25, + 8.0 + ], + [ + 104.75, + 8.0 + ], + [ + 105.25, + 7.0 + ], + [ + 108.75, + 7.0 + ], + [ + 109.25, + 6.0 + ], + [ + 116.75, + 6.0 + ], + [ + 117.25, + 7.0 + ], + [ + 120.75, + 7.0 + ], + [ + 121.25, + 8.0 + ], + [ + 122.75, + 8.0 + ], + [ + 123.25, + 9.0 + ], + [ + 124.75, + 9.0 + ], + [ + 125.0, + 9.75 + ], + [ + 125.75, + 10.0 + ], + [ + 126.0, + 10.75 + ], + [ + 126.75, + 11.0 + ], + [ + 127.0, + 11.75 + ], + [ + 127.75, + 12.0 + ], + [ + 128.0, + 12.75 + ], + [ + 130.0, + 14.25 + ], + [ + 130.0, + 15.75 + ], + [ + 131.0, + 16.25 + ], + [ + 131.0, + 17.75 + ], + [ + 132.0, + 18.25 + ], + [ + 132.0, + 20.75 + ], + [ + 133.0, + 21.25 + ], + [ + 133.0, + 39.75 + ], + [ + 132.0, + 40.25 + ], + [ + 132.0, + 45.75 + ], + [ + 131.0, + 46.25 + ], + [ + 131.0, + 51.75 + ], + [ + 130.0, + 52.25 + ], + [ + 130.0, + 55.75 + ], + [ + 131.0, + 56.25 + ], + [ + 131.0, + 59.75 + ], + [ + 132.0, + 60.25 + ], + [ + 132.0, + 61.75 + ], + [ + 132.75, + 62.0 + ], + [ + 133.0, + 62.75 + ], + [ + 135.0, + 64.25 + ], + [ + 135.0, + 65.75 + ], + [ + 135.75, + 66.0 + ], + [ + 136.0, + 66.75 + ], + [ + 136.75, + 67.0 + ], + [ + 137.0, + 67.75 + ], + [ + 137.75, + 68.0 + ], + [ + 139.25, + 70.0 + ], + [ + 140.75, + 70.0 + ], + [ + 141.0, + 70.75 + ], + [ + 141.75, + 71.0 + ], + [ + 142.0, + 71.75 + ], + [ + 142.75, + 72.0 + ], + [ + 143.0, + 72.75 + ], + [ + 143.75, + 73.0 + ], + [ + 144.0, + 73.75 + ], + [ + 144.75, + 74.0 + ], + [ + 145.0, + 74.75 + ], + [ + 147.0, + 76.25 + ], + [ + 147.0, + 77.75 + ], + [ + 147.75, + 78.0 + ], + [ + 148.0, + 78.75 + ], + [ + 148.75, + 79.0 + ], + [ + 149.0, + 79.75 + ], + [ + 151.0, + 81.25 + ], + [ + 151.0, + 82.75 + ], + [ + 152.0, + 83.25 + ], + [ + 152.0, + 107.75 + ], + [ + 151.25, + 108.0 + ], + [ + 151.0, + 108.75 + ], + [ + 150.25, + 109.0 + ], + [ + 150.0, + 109.75 + ], + [ + 149.25, + 110.0 + ], + [ + 149.0, + 110.75 + ], + [ + 148.25, + 111.0 + ], + [ + 146.75, + 113.0 + ], + [ + 145.25, + 113.0 + ], + [ + 144.75, + 114.0 + ], + [ + 143.25, + 114.0 + ], + [ + 142.75, + 115.0 + ], + [ + 141.25, + 115.0 + ], + [ + 140.75, + 116.0 + ], + [ + 138.25, + 116.0 + ], + [ + 137.75, + 117.0 + ], + [ + 134.25, + 117.0 + ], + [ + 133.75, + 118.0 + ], + [ + 128.25, + 118.0 + ], + [ + 127.75, + 119.0 + ], + [ + 121.25, + 119.0 + ], + [ + 120.75, + 120.0 + ], + [ + 116.25, + 120.0 + ], + [ + 115.75, + 121.0 + ], + [ + 113.25, + 121.0 + ], + [ + 112.75, + 122.0 + ], + [ + 111.25, + 122.0 + ], + [ + 109.75, + 124.0 + ], + [ + 108.25, + 124.0 + ], + [ + 108.0, + 125.75 + ], + [ + 106.0, + 127.25 + ], + [ + 106.0, + 128.75 + ], + [ + 104.0, + 130.25 + ], + [ + 104.0, + 131.75 + ], + [ + 103.0, + 132.25 + ], + [ + 103.0, + 134.75 + ], + [ + 102.0, + 135.25 + ], + [ + 102.0, + 136.75 + ], + [ + 101.0, + 137.25 + ], + [ + 101.0, + 138.75 + ], + [ + 100.0, + 139.25 + ], + [ + 100.0, + 140.75 + ], + [ + 98.0, + 142.25 + ], + [ + 98.0, + 143.75 + ], + [ + 97.0, + 144.25 + ], + [ + 97.0, + 145.75 + ], + [ + 95.0, + 147.25 + ], + [ + 95.0, + 148.75 + ], + [ + 94.25, + 149.0 + ], + [ + 94.0, + 149.75 + ], + [ + 93.25, + 150.0 + ], + [ + 91.75, + 152.0 + ], + [ + 61.25, + 152.0 + ], + [ + 61.0, + 151.25 + ], + [ + 60.25, + 151.0 + ], + [ + 60.0, + 150.25 + ], + [ + 58.0, + 148.75 + ], + [ + 58.0, + 147.25 + ], + [ + 56.0, + 145.75 + ], + [ + 56.0, + 144.25 + ], + [ + 55.0, + 143.75 + ], + [ + 55.0, + 142.25 + ], + [ + 53.0, + 140.75 + ], + [ + 53.0, + 139.25 + ], + [ + 52.0, + 138.75 + ], + [ + 52.0, + 137.25 + ], + [ + 51.0, + 136.75 + ], + [ + 51.0, + 135.25 + ], + [ + 50.0, + 134.75 + ], + [ + 50.0, + 132.25 + ], + [ + 49.0, + 131.75 + ], + [ + 49.0, + 130.25 + ], + [ + 47.0, + 128.75 + ], + [ + 47.0, + 127.25 + ], + [ + 45.0, + 125.75 + ], + [ + 44.75, + 124.0 + ], + [ + 43.25, + 124.0 + ], + [ + 41.75, + 122.0 + ], + [ + 40.25, + 122.0 + ], + [ + 39.75, + 121.0 + ], + [ + 37.25, + 121.0 + ], + [ + 36.75, + 120.0 + ], + [ + 32.25, + 120.0 + ], + [ + 31.75, + 119.0 + ], + [ + 25.25, + 119.0 + ], + [ + 24.75, + 118.0 + ], + [ + 19.25, + 118.0 + ], + [ + 18.75, + 117.0 + ], + [ + 15.25, + 117.0 + ], + [ + 14.75, + 116.0 + ], + [ + 12.25, + 116.0 + ], + [ + 11.75, + 115.0 + ], + [ + 10.25, + 115.0 + ], + [ + 9.75, + 114.0 + ], + [ + 8.25, + 114.0 + ], + [ + 7.75, + 113.0 + ], + [ + 6.25, + 113.0 + ], + [ + 6.0, + 112.25 + ], + [ + 5.25, + 112.0 + ], + [ + 5.0, + 111.25 + ], + [ + 4.25, + 111.0 + ], + [ + 4.0, + 110.25 + ], + [ + 3.25, + 110.0 + ], + [ + 3.0, + 109.25 + ], + [ + 2.25, + 109.0 + ], + [ + 2.0, + 108.25 + ], + [ + 0.0, + 106.75 + ], + [ + 0.0, + 85.25 + ], + [ + 1.0, + 84.75 + ], + [ + 1.0, + 83.25 + ], + [ + 2.0, + 82.75 + ], + [ + 2.0, + 81.25 + ], + [ + 2.75, + 81.0 + ], + [ + 3.0, + 80.25 + ], + [ + 3.75, + 80.0 + ], + [ + 4.0, + 79.25 + ], + [ + 6.0, + 77.75 + ], + [ + 6.0, + 76.25 + ], + [ + 6.75, + 76.0 + ], + [ + 7.0, + 75.25 + ], + [ + 7.75, + 75.0 + ], + [ + 8.0, + 74.25 + ], + [ + 8.75, + 74.0 + ], + [ + 9.0, + 73.25 + ], + [ + 9.75, + 73.0 + ], + [ + 10.0, + 72.25 + ], + [ + 10.75, + 72.0 + ], + [ + 12.25, + 70.0 + ], + [ + 13.75, + 70.0 + ], + [ + 14.0, + 69.25 + ], + [ + 14.75, + 69.0 + ], + [ + 15.0, + 68.25 + ], + [ + 15.75, + 68.0 + ], + [ + 16.0, + 67.25 + ], + [ + 18.0, + 65.75 + ], + [ + 18.0, + 64.25 + ], + [ + 18.75, + 64.0 + ], + [ + 19.0, + 63.25 + ], + [ + 21.0, + 61.75 + ], + [ + 21.0, + 60.25 + ], + [ + 22.0, + 59.75 + ], + [ + 22.0, + 56.25 + ], + [ + 23.0, + 55.75 + ], + [ + 23.0, + 52.25 + ], + [ + 22.0, + 51.75 + ], + [ + 22.0, + 46.25 + ], + [ + 21.0, + 45.75 + ], + [ + 21.0, + 40.25 + ], + [ + 20.0, + 39.75 + ], + [ + 20.0, + 21.25 + ], + [ + 21.0, + 20.75 + ], + [ + 21.0, + 18.25 + ], + [ + 22.0, + 17.75 + ], + [ + 22.0, + 16.25 + ], + [ + 23.0, + 15.75 + ], + [ + 23.0, + 14.25 + ], + [ + 23.75, + 14.0 + ], + [ + 24.0, + 13.25 + ], + [ + 24.75, + 13.0 + ], + [ + 25.0, + 12.25 + ], + [ + 25.75, + 12.0 + ], + [ + 26.0, + 11.25 + ], + [ + 26.75, + 11.0 + ], + [ + 28.25, + 9.0 + ], + [ + 29.75, + 9.0 + ], + [ + 30.25, + 8.0 + ], + [ + 31.75, + 8.0 + ], + [ + 32.25, + 7.0 + ], + [ + 35.75, + 7.0 + ] + ], + "0.5": [ + [ + 36.25, + 6.0 + ], + [ + 43.75, + 6.0 + ], + [ + 44.25, + 7.0 + ], + [ + 47.75, + 7.0 + ], + [ + 48.25, + 8.0 + ], + [ + 53.75, + 9.0 + ], + [ + 54.25, + 10.0 + ], + [ + 55.75, + 10.0 + ], + [ + 56.25, + 11.0 + ], + [ + 57.75, + 11.0 + ], + [ + 58.25, + 12.0 + ], + [ + 59.75, + 12.0 + ], + [ + 60.25, + 13.0 + ], + [ + 61.75, + 13.0 + ], + [ + 62.25, + 14.0 + ], + [ + 63.75, + 14.0 + ], + [ + 64.25, + 15.0 + ], + [ + 65.75, + 15.0 + ], + [ + 66.25, + 16.0 + ], + [ + 67.75, + 16.0 + ], + [ + 70.25, + 18.0 + ], + [ + 72.75, + 18.0 + ], + [ + 73.25, + 19.0 + ], + [ + 79.75, + 19.0 + ], + [ + 80.25, + 18.0 + ], + [ + 82.75, + 18.0 + ], + [ + 83.25, + 17.0 + ], + [ + 84.75, + 17.0 + ], + [ + 85.25, + 16.0 + ], + [ + 86.75, + 16.0 + ], + [ + 87.25, + 15.0 + ], + [ + 88.75, + 15.0 + ], + [ + 89.25, + 14.0 + ], + [ + 90.75, + 14.0 + ], + [ + 91.25, + 13.0 + ], + [ + 92.75, + 13.0 + ], + [ + 93.25, + 12.0 + ], + [ + 94.75, + 12.0 + ], + [ + 95.25, + 11.0 + ], + [ + 96.75, + 11.0 + ], + [ + 99.25, + 9.0 + ], + [ + 101.75, + 9.0 + ], + [ + 102.25, + 8.0 + ], + [ + 104.75, + 8.0 + ], + [ + 105.25, + 7.0 + ], + [ + 108.75, + 7.0 + ], + [ + 109.25, + 6.0 + ], + [ + 116.75, + 6.0 + ], + [ + 117.25, + 7.0 + ], + [ + 120.75, + 7.0 + ], + [ + 121.25, + 8.0 + ], + [ + 124.75, + 9.0 + ], + [ + 130.0, + 14.25 + ], + [ + 130.0, + 15.75 + ], + [ + 132.0, + 18.25 + ], + [ + 132.0, + 20.75 + ], + [ + 133.0, + 21.25 + ], + [ + 133.0, + 39.75 + ], + [ + 132.0, + 40.25 + ], + [ + 132.0, + 45.75 + ], + [ + 131.0, + 46.25 + ], + [ + 131.0, + 51.75 + ], + [ + 130.0, + 52.25 + ], + [ + 131.0, + 59.75 + ], + [ + 132.0, + 60.25 + ], + [ + 132.0, + 61.75 + ], + [ + 135.0, + 64.25 + ], + [ + 135.0, + 65.75 + ], + [ + 139.25, + 70.0 + ], + [ + 140.75, + 70.0 + ], + [ + 147.0, + 76.25 + ], + [ + 147.0, + 77.75 + ], + [ + 151.0, + 81.25 + ], + [ + 151.0, + 82.75 + ], + [ + 152.0, + 83.25 + ], + [ + 152.0, + 107.75 + ], + [ + 146.75, + 113.0 + ], + [ + 145.25, + 113.0 + ], + [ + 144.75, + 114.0 + ], + [ + 143.25, + 114.0 + ], + [ + 140.75, + 116.0 + ], + [ + 138.25, + 116.0 + ], + [ + 137.75, + 117.0 + ], + [ + 134.25, + 117.0 + ], + [ + 133.75, + 118.0 + ], + [ + 128.25, + 118.0 + ], + [ + 127.75, + 119.0 + ], + [ + 121.25, + 119.0 + ], + [ + 120.75, + 120.0 + ], + [ + 116.25, + 120.0 + ], + [ + 115.75, + 121.0 + ], + [ + 113.25, + 121.0 + ], + [ + 112.75, + 122.0 + ], + [ + 111.25, + 122.0 + ], + [ + 109.75, + 124.0 + ], + [ + 108.25, + 124.0 + ], + [ + 108.0, + 125.75 + ], + [ + 106.0, + 127.25 + ], + [ + 106.0, + 128.75 + ], + [ + 104.0, + 130.25 + ], + [ + 104.0, + 131.75 + ], + [ + 103.0, + 132.25 + ], + [ + 103.0, + 134.75 + ], + [ + 102.0, + 135.25 + ], + [ + 102.0, + 136.75 + ], + [ + 101.0, + 137.25 + ], + [ + 100.0, + 140.75 + ], + [ + 98.0, + 142.25 + ], + [ + 97.0, + 145.75 + ], + [ + 95.0, + 147.25 + ], + [ + 95.0, + 148.75 + ], + [ + 91.75, + 152.0 + ], + [ + 61.25, + 152.0 + ], + [ + 58.0, + 148.75 + ], + [ + 58.0, + 147.25 + ], + [ + 56.0, + 145.75 + ], + [ + 55.0, + 142.25 + ], + [ + 53.0, + 140.75 + ], + [ + 53.0, + 139.25 + ], + [ + 52.0, + 138.75 + ], + [ + 52.0, + 137.25 + ], + [ + 50.0, + 134.75 + ], + [ + 50.0, + 132.25 + ], + [ + 49.0, + 131.75 + ], + [ + 49.0, + 130.25 + ], + [ + 47.0, + 128.75 + ], + [ + 47.0, + 127.25 + ], + [ + 45.0, + 125.75 + ], + [ + 44.75, + 124.0 + ], + [ + 43.25, + 124.0 + ], + [ + 41.75, + 122.0 + ], + [ + 40.25, + 122.0 + ], + [ + 39.75, + 121.0 + ], + [ + 37.25, + 121.0 + ], + [ + 36.75, + 120.0 + ], + [ + 32.25, + 120.0 + ], + [ + 31.75, + 119.0 + ], + [ + 25.25, + 119.0 + ], + [ + 24.75, + 118.0 + ], + [ + 19.25, + 118.0 + ], + [ + 18.75, + 117.0 + ], + [ + 15.25, + 117.0 + ], + [ + 14.75, + 116.0 + ], + [ + 12.25, + 116.0 + ], + [ + 11.75, + 115.0 + ], + [ + 10.25, + 115.0 + ], + [ + 9.75, + 114.0 + ], + [ + 6.25, + 113.0 + ], + [ + 0.0, + 106.75 + ], + [ + 0.0, + 85.25 + ], + [ + 1.0, + 84.75 + ], + [ + 2.0, + 81.25 + ], + [ + 6.0, + 77.75 + ], + [ + 6.0, + 76.25 + ], + [ + 12.25, + 70.0 + ], + [ + 13.75, + 70.0 + ], + [ + 18.0, + 65.75 + ], + [ + 18.0, + 64.25 + ], + [ + 21.0, + 61.75 + ], + [ + 21.0, + 60.25 + ], + [ + 22.0, + 59.75 + ], + [ + 23.0, + 52.25 + ], + [ + 22.0, + 51.75 + ], + [ + 22.0, + 46.25 + ], + [ + 21.0, + 45.75 + ], + [ + 21.0, + 40.25 + ], + [ + 20.0, + 39.75 + ], + [ + 20.0, + 21.25 + ], + [ + 21.0, + 20.75 + ], + [ + 21.0, + 18.25 + ], + [ + 22.0, + 17.75 + ], + [ + 23.0, + 14.25 + ], + [ + 28.25, + 9.0 + ], + [ + 29.75, + 9.0 + ], + [ + 32.25, + 7.0 + ], + [ + 35.75, + 7.0 + ] + ], + "1.0": [ + [ + 36.25, + 6.0 + ], + [ + 43.75, + 6.0 + ], + [ + 53.75, + 9.0 + ], + [ + 73.25, + 19.0 + ], + [ + 82.75, + 18.0 + ], + [ + 99.25, + 9.0 + ], + [ + 109.25, + 6.0 + ], + [ + 116.75, + 6.0 + ], + [ + 124.75, + 9.0 + ], + [ + 130.0, + 14.25 + ], + [ + 133.0, + 21.25 + ], + [ + 133.0, + 39.75 + ], + [ + 130.0, + 52.25 + ], + [ + 131.0, + 59.75 + ], + [ + 135.0, + 65.75 + ], + [ + 147.0, + 76.25 + ], + [ + 152.0, + 83.25 + ], + [ + 152.0, + 107.75 + ], + [ + 146.75, + 113.0 + ], + [ + 140.75, + 116.0 + ], + [ + 116.25, + 120.0 + ], + [ + 108.25, + 124.0 + ], [ - 36.0, - 6.0 + 103.0, + 132.25 ], [ - 53.0, - 9.0 + 100.0, + 140.75 ], [ - 73.0, - 19.0 + 91.75, + 152.0 ], [ - 82.0, - 18.0 + 61.25, + 152.0 ], [ - 109.0, - 6.0 + 53.0, + 140.75 ], [ - 124.0, - 9.0 + 50.0, + 132.25 ], [ - 132.0, - 21.0 + 44.75, + 124.0 ], [ - 130.0, - 59.0 + 36.75, + 120.0 ], [ - 151.0, - 83.0 + 19.25, + 118.0 ], [ - 151.0, - 107.0 + 6.25, + 113.0 ], [ - 140.0, - 115.0 + 0.0, + 106.75 ], [ - 108.0, - 123.0 + 0.0, + 85.25 ], [ - 91.0, - 151.0 + 6.0, + 76.25 ], [ - 61.0, - 151.0 + 18.0, + 65.75 ], [ - 44.0, - 123.0 + 22.0, + 59.75 ], [ - 6.0, - 112.0 + 23.0, + 52.25 ], [ - 0.0, - 106.0 + 20.0, + 39.75 ], [ - 0.0, - 85.0 + 20.0, + 21.25 ], [ - 22.0, - 59.0 + 23.0, + 14.25 ], [ - 20.0, - 21.0 + 28.25, + 9.0 + ] + ], + "16.0": [ + [ + 36.25, + 6.0 ], [ - 28.0, + 124.75, 9.0 + ], + [ + 152.0, + 107.75 + ], + [ + 91.75, + 152.0 + ], + [ + 6.25, + 113.0 ] ], - "coarse": [ + "2.0": [ [ - 36.0, + 36.25, 6.0 ], [ - 73.0, + 53.75, + 9.0 + ], + [ + 73.25, 19.0 ], [ - 109.0, + 82.75, + 18.0 + ], + [ + 109.25, 6.0 ], [ - 124.0, + 124.75, 9.0 ], [ - 132.0, - 21.0 + 133.0, + 21.25 ], [ - 130.0, - 59.0 + 131.0, + 59.75 ], [ - 151.0, - 83.0 + 152.0, + 83.25 ], [ - 151.0, - 107.0 + 152.0, + 107.75 ], [ - 108.0, - 123.0 + 140.75, + 116.0 ], [ - 91.0, - 151.0 + 108.25, + 124.0 ], [ - 61.0, - 151.0 + 91.75, + 152.0 ], [ - 44.0, - 123.0 + 61.25, + 152.0 ], [ - 6.0, - 112.0 + 44.75, + 124.0 + ], + [ + 6.25, + 113.0 + ], + [ + 0.0, + 106.75 ], [ 0.0, - 85.0 + 85.25 ], [ 22.0, - 59.0 + 59.75 ], [ 20.0, - 21.0 + 21.25 + ], + [ + 28.25, + 9.0 ] ], - "fine": [ + "4.0": [ [ - 36.0, + 36.25, 6.0 ], [ - 43.0, + 73.25, + 19.0 + ], + [ + 109.25, 6.0 ], [ - 53.0, + 124.75, 9.0 ], [ - 73.0, - 19.0 + 133.0, + 21.25 ], [ - 82.0, - 18.0 + 131.0, + 59.75 ], [ - 99.0, - 9.0 + 152.0, + 83.25 ], [ - 109.0, - 6.0 + 152.0, + 107.75 ], [ - 116.0, - 6.0 + 108.25, + 124.0 ], [ - 124.0, - 9.0 + 91.75, + 152.0 ], [ - 129.0, - 14.0 + 61.25, + 152.0 ], [ - 132.0, - 21.0 + 44.75, + 124.0 ], [ - 132.0, - 39.0 + 6.25, + 113.0 ], [ - 129.0, - 52.0 + 0.0, + 106.75 ], [ - 130.0, - 59.0 + 0.0, + 85.25 ], [ - 134.0, - 65.0 + 22.0, + 59.75 ], [ - 146.0, - 76.0 + 20.0, + 21.25 + ] + ], + "8.0": [ + [ + 36.25, + 6.0 ], [ - 151.0, - 83.0 + 73.25, + 19.0 ], [ - 151.0, - 107.0 + 124.75, + 9.0 ], [ - 146.0, - 112.0 + 152.0, + 107.75 ], [ - 140.0, - 115.0 + 108.25, + 124.0 ], [ - 116.0, - 119.0 + 91.75, + 152.0 ], [ - 108.0, - 123.0 + 61.25, + 152.0 ], [ - 103.0, - 130.0 + 44.75, + 124.0 ], [ - 96.0, - 145.0 + 6.25, + 113.0 ], [ - 91.0, - 151.0 + 0.0, + 85.25 + ], + [ + 22.0, + 59.75 + ], + [ + 20.0, + 21.25 + ] + ] + } + }, + { + "contour": [ + [ + 10.25, + 10.0 + ], + [ + 60.75, + 10.0 + ], + [ + 61.0, + 60.75 + ], + [ + 10.25, + 61.0 + ], + [ + 10.0, + 10.25 + ] + ], + "name": "rectangle", + "polygon": { + "0.25": [ + [ + 10.25, + 10.0 + ], + [ + 60.75, + 10.0 ], [ 61.0, - 151.0 + 60.75 ], [ - 56.0, - 145.0 + 10.25, + 61.0 + ] + ], + "0.5": [ + [ + 10.25, + 10.0 ], [ - 49.0, - 130.0 + 60.75, + 10.0 ], [ - 44.0, - 123.0 + 61.0, + 60.75 ], [ - 36.0, - 119.0 + 10.25, + 61.0 + ] + ], + "1.0": [ + [ + 10.25, + 10.0 ], [ - 19.0, - 117.0 + 60.75, + 10.0 ], [ - 6.0, - 112.0 + 61.0, + 60.75 ], [ - 0.0, - 106.0 + 10.25, + 61.0 + ] + ], + "16.0": [ + [ + 10.25, + 10.0 ], [ - 0.0, - 85.0 + 60.75, + 10.0 ], [ - 6.0, - 76.0 + 61.0, + 60.75 ], [ - 18.0, - 65.0 + 10.25, + 61.0 + ] + ], + "2.0": [ + [ + 10.25, + 10.0 ], [ - 22.0, - 59.0 + 60.75, + 10.0 ], [ - 23.0, - 52.0 + 61.0, + 60.75 ], [ - 20.0, - 39.0 + 10.25, + 61.0 + ] + ], + "4.0": [ + [ + 10.25, + 10.0 ], [ - 20.0, - 21.0 + 60.75, + 10.0 ], [ - 23.0, - 14.0 + 61.0, + 60.75 ], [ - 28.0, - 9.0 + 10.25, + 61.0 + ] + ], + "8.0": [ + [ + 10.25, + 10.0 + ], + [ + 60.75, + 10.0 + ], + [ + 61.0, + 60.75 + ], + [ + 10.25, + 61.0 ] ] - }, - "tolerance": { - "balanced": 2.0934660255184463, - "coarse": 5.233665063796116, - "fine": 0.8373864102073785 } }, { "contour": [ [ - 10.0, + 10.25, 10.0 ], [ - 60.0, + 80.75, 10.0 ], [ - 60.0, - 60.0 + 81.0, + 14.75 ], [ - 10.0, - 60.0 + 10.25, + 15.0 ], [ 10.0, - 11.0 + 10.25 ] ], - "name": "rectangle", + "name": "thin-rectangle", "polygon": { - "balanced": [ + "0.25": [ [ - 10.0, + 10.25, 10.0 ], [ - 60.0, + 80.75, 10.0 ], [ - 60.0, - 60.0 + 81.0, + 14.75 ], [ - 10.0, - 60.0 + 10.25, + 15.0 + ] + ], + "0.5": [ + [ + 10.25, + 10.0 + ], + [ + 80.75, + 10.0 + ], + [ + 81.0, + 14.75 + ], + [ + 10.25, + 15.0 ] ], - "coarse": [ + "1.0": [ [ - 10.0, + 10.25, 10.0 ], [ - 60.0, + 80.75, 10.0 ], [ - 60.0, - 60.0 + 81.0, + 14.75 ], [ - 10.0, - 60.0 + 10.25, + 15.0 ] ], - "fine": [ - [ - 10.0, - 10.0 - ], + "16.0": [ [ - 60.0, + 10.25, 10.0 ], [ - 60.0, - 60.0 + 81.0, + 14.75 ], [ 10.0, - 60.0 + 10.25 ] - ] - }, - "tolerance": { - "balanced": 0.7071067811865476, - "coarse": 1.7677669529663689, - "fine": 0.5 - } - }, - { - "contour": [ - [ - 10.0, - 10.0 - ], - [ - 80.0, - 10.0 - ], - [ - 80.0, - 14.0 ], - [ - 10.0, - 14.0 - ], - [ - 10.0, - 11.0 - ] - ], - "name": "thin-rectangle", - "polygon": { - "balanced": [ + "2.0": [ [ - 10.0, + 10.25, 10.0 ], [ - 80.0, + 80.75, 10.0 ], [ - 80.0, - 14.0 + 81.0, + 14.75 ], [ - 10.0, - 14.0 + 10.25, + 15.0 ] ], - "coarse": [ + "4.0": [ [ - 10.0, + 10.25, 10.0 ], [ - 80.0, + 80.75, 10.0 ], [ - 80.0, - 14.0 + 81.0, + 14.75 ], [ - 10.0, - 14.0 + 10.25, + 15.0 ] ], - "fine": [ - [ - 10.0, - 10.0 - ], + "8.0": [ [ - 80.0, + 10.25, 10.0 ], [ - 80.0, - 14.0 + 81.0, + 14.75 ], [ 10.0, - 14.0 + 10.25 ] ] - }, - "tolerance": { - "balanced": 0.7011419257183242, - "coarse": 1.7528548142958105, - "fine": 0.5 } }, { "contour": [ [ - 8.0, + 8.25, + 4.0 + ], + [ + 8.75, 4.0 ], + [ + 9.25, + 5.0 + ], + [ + 10.75, + 5.0 + ], [ 11.0, - 6.0 + 5.75 ], [ 12.0, - 8.0 + 6.25 ], [ - 11.0, - 10.0 + 12.0, + 7.75 ], [ - 8.0, + 13.0, + 8.25 + ], + [ + 13.0, + 8.75 + ], + [ + 12.0, + 9.25 + ], + [ + 12.0, + 10.75 + ], + [ + 11.25, + 11.0 + ], + [ + 10.75, 12.0 ], + [ + 9.25, + 12.0 + ], + [ + 8.75, + 13.0 + ], + [ + 8.25, + 13.0 + ], + [ + 7.75, + 12.0 + ], + [ + 6.25, + 12.0 + ], + [ + 6.0, + 11.25 + ], [ 5.0, - 10.0 + 10.75 + ], + [ + 5.0, + 9.25 + ], + [ + 4.25, + 9.0 ], [ 4.0, - 8.0 + 8.25 + ], + [ + 5.0, + 7.75 ], [ 5.0, + 6.25 + ], + [ + 5.75, 6.0 ], [ - 7.0, + 6.25, 5.0 + ], + [ + 7.75, + 5.0 + ], + [ + 8.0, + 4.25 ] ], "name": "tiny-disc", "polygon": { - "balanced": [ + "0.25": [ [ - 8.0, + 8.25, + 4.0 + ], + [ + 8.75, 4.0 ], + [ + 9.25, + 5.0 + ], + [ + 10.75, + 5.0 + ], [ 11.0, - 6.0 + 5.75 ], [ 12.0, - 8.0 + 6.25 ], [ - 11.0, - 10.0 + 12.0, + 7.75 ], [ - 8.0, + 13.0, + 8.25 + ], + [ + 13.0, + 8.75 + ], + [ + 12.0, + 9.25 + ], + [ + 12.0, + 10.75 + ], + [ + 11.25, + 11.0 + ], + [ + 10.75, + 12.0 + ], + [ + 9.25, + 12.0 + ], + [ + 8.75, + 13.0 + ], + [ + 8.25, + 13.0 + ], + [ + 7.75, + 12.0 + ], + [ + 6.25, 12.0 ], + [ + 6.0, + 11.25 + ], [ 5.0, - 10.0 + 10.75 + ], + [ + 5.0, + 9.25 + ], + [ + 4.25, + 9.0 ], [ 4.0, - 8.0 + 8.25 + ], + [ + 5.0, + 7.75 ], [ 5.0, + 6.25 + ], + [ + 5.75, 6.0 + ], + [ + 6.25, + 5.0 + ], + [ + 7.75, + 5.0 ] ], - "coarse": [ + "0.5": [ [ - 8.0, + 8.25, 4.0 ], [ - 11.0, - 6.0 + 12.0, + 6.25 ], [ 12.0, - 8.0 + 7.75 ], [ - 11.0, - 10.0 + 13.0, + 8.25 ], [ - 8.0, + 12.0, + 9.25 + ], + [ + 12.0, + 10.75 + ], + [ + 10.75, 12.0 ], + [ + 9.25, + 12.0 + ], + [ + 8.75, + 13.0 + ], [ 5.0, - 10.0 + 10.75 + ], + [ + 5.0, + 9.25 ], [ 4.0, - 8.0 + 8.25 ], [ 5.0, - 6.0 + 7.75 + ], + [ + 5.0, + 6.25 ] ], - "fine": [ + "1.0": [ [ - 8.0, + 8.25, 4.0 ], [ - 11.0, - 6.0 + 12.0, + 6.25 ], [ 12.0, - 8.0 + 10.75 ], [ - 11.0, - 10.0 + 8.75, + 13.0 ], [ - 8.0, - 12.0 + 5.0, + 10.75 + ], + [ + 4.0, + 8.25 + ] + ], + "16.0": null, + "2.0": [ + [ + 8.25, + 4.0 + ], + [ + 12.0, + 6.25 + ], + [ + 12.0, + 10.75 + ], + [ + 8.75, + 13.0 ], [ 5.0, - 10.0 + 10.75 + ] + ], + "4.0": [ + [ + 8.25, + 4.0 ], [ - 4.0, - 8.0 + 12.0, + 10.75 ], [ 5.0, - 6.0 + 10.75 ] - ] - }, - "tolerance": { - "balanced": 0.5, - "coarse": 0.5, - "fine": 0.5 + ], + "8.0": null } }, { - "contour": [ - [ - 10.0, - 10.0 - ], - [ - 11.0, - 10.0 - ] - ], - "name": "two-pixels", + "contour": [], + "name": "nothing", "polygon": { - "balanced": null, - "coarse": null, - "fine": null - }, - "tolerance": { - "balanced": 0.5, - "coarse": 0.5, - "fine": 0.5 + "0.25": null, + "0.5": null, + "1.0": null, + "16.0": null, + "2.0": null, + "4.0": null, + "8.0": null } } ], - "epsilon": { - "balanced": 0.01, - "coarse": 0.025, - "fine": 0.004 - }, - "minimum_tolerance": 0.5 + "default_tolerance": 1.0, + "maximum_tolerance": 16.0, + "minimum_tolerance": 0.25, + "tolerances": [ + 0.25, + 0.5, + 1.0, + 2.0, + 4.0, + 8.0, + 16.0 + ] } diff --git a/tests/inference/test_simplification_fixture.py b/tests/inference/test_simplification_fixture.py index 66b77f1a..cfda5f46 100644 --- a/tests/inference/test_simplification_fixture.py +++ b/tests/inference/test_simplification_fixture.py @@ -3,8 +3,8 @@ `tests/fixtures/simplification.json` is a committed artifact, and the only thing that carries this algorithm across the language boundary: the `frontend` CI job installs no Python and reads what is in the repository. The editor re-simplifies -a contour locally so that moving `detail` costs no round trip, and this module is -what makes "the two agree" checkable rather than asserted. +a contour locally so that moving the tolerance costs no round trip, and this +module is what makes "the two agree" checkable rather than asserted. So it needs two independent links, the shape `openapi.json` and its generated client already have. This is the first — the fixture is the application's own @@ -25,10 +25,9 @@ from typing import Any import pytest -from scripts.export_simplification_fixtures import OUTPUT_PATH, build_fixture +from scripts.export_simplification_fixtures import OUTPUT_PATH, TOLERANCES, build_fixture -from visionset.inference.masks import EPSILON, MINIMUM_TOLERANCE -from visionset.kernel.domain import Detail +from visionset.kernel.domain import DEFAULT_TOLERANCE, MAXIMUM_TOLERANCE, MINIMUM_TOLERANCE REPO_ROOT = Path(__file__).resolve().parents[2] @@ -49,37 +48,35 @@ def test_the_fixture_carries_the_constants_the_port_needs() -> None: """The TypeScript reads these rather than restating them, so they have to travel.""" payload = committed() assert payload["minimum_tolerance"] == MINIMUM_TOLERANCE - assert payload["epsilon"] == {step.value: EPSILON[step] for step in Detail} + assert payload["default_tolerance"] == DEFAULT_TOLERANCE + assert payload["maximum_tolerance"] == MAXIMUM_TOLERANCE + assert payload["tolerances"] == TOLERANCES -def test_every_step_of_the_vocabulary_is_covered() -> None: +def test_every_tolerance_is_covered_by_every_case() -> None: for case in committed()["cases"]: - assert set(case["polygon"]) == {step.value for step in Detail} - assert set(case["tolerance"]) == {step.value for step in Detail} + assert set(case["polygon"]) == {str(t) for t in TOLERANCES} -def test_a_case_exists_whose_vertex_count_moves_with_every_step() -> None: - """Without one, a port that ignored `detail` would pass the whole gate. - - Every straight-edged case answers four corners at all three settings, which - is correct and proves nothing about the setting. - """ +def test_a_case_exists_whose_vertex_count_moves_with_the_tolerance() -> None: + """Without one, a port that ignored the tolerance would pass the whole gate.""" moving = [ case for case in committed()["cases"] - if len({len(points) for points in case["polygon"].values() if points is not None}) == 3 + if len({len(points) for points in case["polygon"].values() if points is not None}) >= 4 ] - assert moving, "no case tells the three steps apart" + assert moving, "no case tells the tolerances apart" -def test_a_case_exists_where_the_floor_decides_rather_than_the_ratio() -> None: - """The other branch of `tolerance_for`, and the one a port most easily leaves out.""" - floored = [ +def test_a_case_exists_that_is_a_polygon_at_the_floor_and_refused_at_the_ceiling() -> None: + """The ends decide something a middle tolerance does not.""" + turning = [ case for case in committed()["cases"] - if set(case["tolerance"].values()) == {MINIMUM_TOLERANCE} + if case["polygon"][str(MINIMUM_TOLERANCE)] is not None + and case["polygon"][str(MAXIMUM_TOLERANCE)] is None ] - assert floored, "no case reaches the minimum tolerance on every step" + assert turning, "no case is a shape at the floor and nothing at the ceiling" def test_a_case_exists_that_cannot_be_a_polygon_at_all() -> None: From 042e152b6f5f38327fc4beb626a71792c4fec789 Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Tue, 25 Aug 2026 07:30:41 -0700 Subject: [PATCH 4/7] feat(annotator): the suggestion's outline follows a pixel tolerance The editor's simplifier mirrors the kernel's Douglas-Peucker at an absolute tolerance; the brackets double and halve it along the power-of-two ladder, snapping a slider-chosen value to the nearest stop first. A coarse tolerance that leaves nothing keeps the suggestions, so a finer one restores the shape; the preview and the count read nothing while there is nothing to show. --- .../src/adapters/react/TransientLayer.tsx | 4 +- .../src/adapters/react/paint.test.ts | 2 +- .../annotator/src/adapters/react/paint.ts | 5 +- .../src/core/geometry/simplify.test.ts | 102 +++++++++--------- .../annotator/src/core/geometry/simplify.ts | 92 ++++++---------- frontend/annotator/src/core/input/actions.ts | 12 +-- .../src/core/interaction/suggestion.test.ts | 81 ++++++++------ .../src/core/interaction/suggestion.ts | 76 ++++++------- frontend/annotator/src/index.ts | 12 +-- 9 files changed, 186 insertions(+), 200 deletions(-) diff --git a/frontend/annotator/src/adapters/react/TransientLayer.tsx b/frontend/annotator/src/adapters/react/TransientLayer.tsx index 94e5c2f2..1b2ed549 100644 --- a/frontend/annotator/src/adapters/react/TransientLayer.tsx +++ b/frontend/annotator/src/adapters/react/TransientLayer.tsx @@ -225,8 +225,8 @@ function SuggestedShape({ is not one — selection carries the panel row, the delete key and the keyboard rules a preview must not have. So it gets its own rule: the vertices are up the whole time the preview is, undecimated at every step, - because where precision was gained or lost *is* what `detail` is about and - a counter alone made it a blind control (#557). + because where precision was gained or lost *is* what the tolerance is about and + a counter alone made it a blind control. Polygons only. A box has no vertex list — its corners are grips, and a preview has nothing to drag. diff --git a/frontend/annotator/src/adapters/react/paint.test.ts b/frontend/annotator/src/adapters/react/paint.test.ts index f97192da..56a44f46 100644 --- a/frontend/annotator/src/adapters/react/paint.test.ts +++ b/frontend/annotator/src/adapters/react/paint.test.ts @@ -50,7 +50,7 @@ function answerOf(...suggestions: readonly Suggestion[]): Answer { modelRef: MODEL_REF, confidence: suggestions[0]?.confidence ?? null, suggestions, - parameters: ["detail"], + parameters: ["tolerance"], }; } diff --git a/frontend/annotator/src/adapters/react/paint.ts b/frontend/annotator/src/adapters/react/paint.ts index 45de160f..535cedc9 100644 --- a/frontend/annotator/src/adapters/react/paint.ts +++ b/frontend/annotator/src/adapters/react/paint.ts @@ -287,7 +287,9 @@ export interface PaintedSuggestion { * would flicker on every press". The engine kept its half of that and the renderer * threw it away, so the flicker it argues against was happening on every press. * A held suggestion is the best answer anyone has until a better one arrives, - * whichever status is carrying it. + * whichever status is carrying it. The one status that does decide is `none`, + * which holds the shapes the current tolerance reduced to nothing so that a + * finer one can re-derive them — held to be re-simplified, not to be drawn. * * The **points are carried whatever the status**, which is why the caller checks * for them separately: the dots are what makes a refine click legible, and they @@ -309,6 +311,7 @@ export function paintSuggestions( // of guard as the one below: what the type allows, not what the machine does. const labelClass = state.labelClass; if (labelClass === null) return []; + if (state.status === "none") return []; const color = classColor(declared, labelClass); const painted: PaintedSuggestion[] = []; for (const suggestion of state.suggestions) { diff --git a/frontend/annotator/src/core/geometry/simplify.test.ts b/frontend/annotator/src/core/geometry/simplify.test.ts index 6886b724..4e3941a4 100644 --- a/frontend/annotator/src/core/geometry/simplify.test.ts +++ b/frontend/annotator/src/core/geometry/simplify.test.ts @@ -3,35 +3,40 @@ * * `tests/fixtures/simplification.json` is written by the kernel and kept current * by `tests/inference/test_simplification_fixture.py`. This proves the port - * reproduces it — exactly, point for point, at every step — which is what lets - * the editor re-simplify locally while the kernel stays authoritative on what is - * written. + * reproduces it — exactly, point for point, at every tolerance — which is what + * lets the editor re-simplify locally while the kernel stays authoritative on + * what is written. * * **Exact equality, deliberately.** A tolerance on the comparison would let a * genuine divergence through: the two implementations either run the same * arithmetic in the same order or they will disagree about a vertex somewhere, - * and "somewhere" is what a golden fixture exists to find. Both languages hold - * IEEE-754 doubles and `Math.sqrt` is Python's `** 0.5`, so equality is - * achievable rather than optimistic. + * and "somewhere" is what a golden fixture exists to find. */ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import type { Point } from "../types"; -import { DETAIL_STEPS, EPSILON, MINIMUM_TOLERANCE, polygonAt, simplified, steppedDetail, toleranceFor } from "./simplify"; -import type { Detail } from "./simplify"; +import { + DEFAULT_TOLERANCE, + MAXIMUM_TOLERANCE, + MINIMUM_TOLERANCE, + polygonAt, + simplified, + steppedTolerance, +} from "./simplify"; interface Case { readonly name: string; readonly contour: readonly (readonly number[])[]; - readonly tolerance: Readonly>; readonly polygon: Readonly>; } interface Fixture { readonly minimum_tolerance: number; - readonly epsilon: Readonly>; + readonly default_tolerance: number; + readonly maximum_tolerance: number; + readonly tolerances: readonly number[]; readonly cases: readonly Case[]; } @@ -41,54 +46,45 @@ const fixture = JSON.parse(readFileSync(FIXTURE_URL, "utf8")) as Fixture; const points = (rows: readonly (readonly number[])[]): Point[] => rows.map((row) => [row[0]!, row[1]!] as Point); +/** The fixture keys a polygon by Python's spelling of the float: `1.0`, not `1`. */ +const keyed = (tolerance: number): string => + Number.isInteger(tolerance) ? `${tolerance}.0` : String(tolerance); + describe("the constants travel rather than being restated", () => { - it("floors the tolerance where the kernel does", () => { + it("floors, defaults and caps the tolerance where the kernel does", () => { expect(MINIMUM_TOLERANCE).toBe(fixture.minimum_tolerance); - }); - - it("means the same thing by each step", () => { - expect({ ...EPSILON }).toEqual(fixture.epsilon); - }); - - it("names the steps the kernel names", () => { - expect([...DETAIL_STEPS].sort()).toEqual(Object.keys(fixture.epsilon).sort()); + expect(DEFAULT_TOLERANCE).toBe(fixture.default_tolerance); + expect(MAXIMUM_TOLERANCE).toBe(fixture.maximum_tolerance); }); }); describe.each(fixture.cases)("$name", (found) => { - it.each([...DETAIL_STEPS])("resolves the same tolerance at %s", (step) => { - expect(toleranceFor(points(found.contour), step)).toBe(found.tolerance[step]); - }); - - it.each([...DETAIL_STEPS])("keeps exactly the same vertices at %s", (step) => { - const expected = found.polygon[step]; - const actual = polygonAt(points(found.contour), step); - if (expected === null || expected === undefined) { + it.each([...fixture.tolerances])("keeps exactly the same vertices at %s px", (tolerance) => { + const expected = found.polygon[keyed(tolerance)]; + expect(expected).toBeDefined(); + const actual = polygonAt(points(found.contour), tolerance); + if (expected === null) { expect(actual).toBeNull(); return; } - expect(actual).toEqual(points(expected)); + expect(actual).toEqual(points(expected!)); }); }); describe("the gate would notice a port that ignored its input", () => { - it("has a case whose vertex count differs at every step", () => { - // Without one, a `polygonAt` that returned the contour unchanged — or that - // used a fixed tolerance — would satisfy every straight-edged case, which - // answers four corners at all three settings and is right to. + it("has a case whose vertex count moves with the tolerance", () => { const moving = fixture.cases.filter((found) => { - const counts = [...DETAIL_STEPS] - .map((step) => found.polygon[step]) + const counts = Object.values(found.polygon) .filter((value): value is readonly (readonly number[])[] => Boolean(value)) .map((value) => value.length); - return new Set(counts).size === DETAIL_STEPS.length; + return new Set(counts).size >= 4; }); expect(moving.length).toBeGreaterThan(0); }); it("has a case that cannot be a polygon at all", () => { const refused = fixture.cases.filter((found) => - [...DETAIL_STEPS].every((step) => found.polygon[step] === null), + Object.values(found.polygon).every((value) => value === null), ); expect(refused.length).toBeGreaterThan(0); }); @@ -120,24 +116,32 @@ describe("simplification on its own", () => { }); }); -describe("stepping through the vocabulary", () => { - it("moves one step at a time", () => { - expect(steppedDetail("balanced", 1)).toBe("fine"); - expect(steppedDetail("balanced", -1)).toBe("coarse"); +describe("stepping the tolerance", () => { + it("doubles for coarser and halves for finer", () => { + expect(steppedTolerance(1, -1)).toBe(2); + expect(steppedTolerance(1, 1)).toBe(0.5); }); it("stops at each end rather than wrapping", () => { - // Held down, a wrapping control takes somebody from the coarsest straight to - // the finest without their having asked for anything in between. - expect(steppedDetail("coarse", -1)).toBe("coarse"); - expect(steppedDetail("fine", 1)).toBe("fine"); + expect(steppedTolerance(MAXIMUM_TOLERANCE, -1)).toBe(MAXIMUM_TOLERANCE); + expect(steppedTolerance(MINIMUM_TOLERANCE, 1)).toBe(MINIMUM_TOLERANCE); }); - it("visits every step on the way across", () => { - const walked: Detail[] = ["coarse"]; - while (walked[walked.length - 1] !== "fine") { - walked.push(steppedDetail(walked[walked.length - 1]!, 1)); + it("walks the fixture's ladder from the floor to the ceiling", () => { + const walked: number[] = [MINIMUM_TOLERANCE]; + while (walked[walked.length - 1]! < MAXIMUM_TOLERANCE) { + walked.push(steppedTolerance(walked[walked.length - 1]!, -1)); } - expect(walked).toEqual([...DETAIL_STEPS]); + expect(walked).toEqual([...fixture.tolerances]); + }); + + it("lands on the ceiling from a value that is not on the ladder", () => { + expect(steppedTolerance(12, -1)).toBe(MAXIMUM_TOLERANCE); + expect(steppedTolerance(0.3, 1)).toBe(MINIMUM_TOLERANCE); + }); + + it("snaps a value between stops to the nearest one before stepping", () => { + expect(steppedTolerance(1.19, -1)).toBe(2); + expect(steppedTolerance(1.19, 1)).toBe(0.5); }); }); diff --git a/frontend/annotator/src/core/geometry/simplify.ts b/frontend/annotator/src/core/geometry/simplify.ts index f9b4a2b2..e31bf000 100644 --- a/frontend/annotator/src/core/geometry/simplify.ts +++ b/frontend/annotator/src/core/geometry/simplify.ts @@ -1,11 +1,11 @@ /** - * Douglas-Peucker over a traced contour, and the vertex density a person picks. + * Douglas-Peucker over a traced contour, at the pixel tolerance a person picks. * * ## Why this exists twice * * The kernel produces the shape that is finally written; this produces the shape * on screen while somebody is still deciding. Asking the server for each step of - * a three-position control would put a network round trip and a model decode + * moving the tolerance would put a network round trip and a model decode * between a keypress and the picture, for an answer that needs neither: the * response already carries the contour, and reducing it is arithmetic. * @@ -31,34 +31,21 @@ * places. * * The contour arriving here is already reduced at {@link MINIMUM_TOLERANCE}, - * because Douglas-Peucker is not nested — reducing at half a pixel and then at + * because Douglas-Peucker is not nested — reducing at a quarter pixel and then at * five does not give what reducing once at five gives — so both sides start from * the same points or they can never be held to the same answer. */ import type { Point } from "../types"; -/** How much of an outline survives. The wire's `detail`, and the same three names. */ -export type Detail = "coarse" | "balanced" | "fine"; +/** What a caller that says nothing gets: an outline within one pixel of the mask. */ +export const DEFAULT_TOLERANCE = 1; -/** Coarsest first, so `[` and `]` move the same direction the list reads. */ -export const DETAIL_STEPS = ["coarse", "balanced", "fine"] as const satisfies readonly Detail[]; +/** The finest setting, and the floor the kernel reduces the contour at. */ +export const MINIMUM_TOLERANCE = 0.25; -/** - * What each step means, as a fraction of the region's bounding diagonal. - * - * Relative rather than absolute, which is the whole reason one setting works at - * every scale: three pixels is nothing on a car and is the whole of a bottle cap. - * The numbers are the kernel's, and the fixture asserts they still are. - */ -export const EPSILON = { - coarse: 0.025, - balanced: 0.01, - fine: 0.004, -} as const satisfies Record; - -/** No tolerance below half a pixel, however small the region. */ -export const MINIMUM_TOLERANCE = 0.5; +/** The coarsest setting. Past this an outline stops describing the object. */ +export const MAXIMUM_TOLERANCE = 16; /** The domain's floor for a polygon: fewer than three points is a line. */ const MINIMUM_POLYGON_POINTS = 3; @@ -108,62 +95,49 @@ export function simplified(points: readonly Point[], tolerance: number): Point[] return points.filter((_, index) => keep[index]!); } -/** The pixel tolerance a step means for a region of this size. */ -export function toleranceFor(points: readonly Point[], detail: Detail): number { - if (points.length === 0) return MINIMUM_TOLERANCE; - const xs = points.map(([x]) => x); - const ys = points.map(([, y]) => y); - const diagonal = Math.sqrt( - (Math.max(...xs) - Math.min(...xs)) ** 2 + (Math.max(...ys) - Math.min(...ys)) ** 2, - ); - return Math.max(MINIMUM_TOLERANCE, EPSILON[detail] * diagonal); -} - /** - * Drop the vertices Douglas-Peucker only kept because it was told to. + * Drop the one vertex Douglas-Peucker only kept because it was told to. * * The algorithm pins the first and last point of what it is given, and what it is - * given here is a ring cut open at an arbitrary pixel — so the final vertex is - * pinned for a reason that stops being true the moment the ring closes, and it - * lands one pixel from the first. Judged by the same tolerance as everything - * else rather than by exact equality: the artifact is a near-duplicate, so - * comparing the first point to the last never fires on the case that motivates it. + * given here is a ring cut open at an arbitrary pixel. Cutting the ring open pins + * exactly one vertex artificially, and it is a near-duplicate of the first point + * (a fraction of a pixel away), so it is dropped when it sits within the + * tolerance of the closing segment; a second drop would remove a real corner the + * reduction chose to keep. */ function closed(kept: Point[], tolerance: number): Point[] { - let ring = kept; - while (ring.length > MINIMUM_POLYGON_POINTS) { - if (distanceToSegment(ring[ring.length - 1]!, ring[ring.length - 2]!, ring[0]!) > tolerance) { - return ring; + if (kept.length > MINIMUM_POLYGON_POINTS) { + const last = kept[kept.length - 1]!; + if (distanceToSegment(last, kept[kept.length - 2]!, kept[0]!) <= tolerance) { + return kept.slice(0, -1); } - ring = ring.slice(0, -1); } - return ring; + return kept; } /** - * That contour at the requested vertex density, or `null` where no polygon can be + * That contour within `tolerance` pixels, or `null` where no polygon can be * made — an empty contour, or one too thin to have three distinct corners. - * - * `null` rather than an empty array, because "there is no shape here" and "the - * shape has no vertices" are different facts and only the first one happens. */ -export function polygonAt(points: readonly Point[], detail: Detail): Point[] | null { +export function polygonAt(points: readonly Point[], tolerance: number): Point[] | null { if (points.length < MINIMUM_POLYGON_POINTS) return null; - const tolerance = toleranceFor(points, detail); const kept = closed(simplified(points, tolerance), tolerance); if (kept.length < MINIMUM_POLYGON_POINTS) return null; return kept; } /** - * The next step in a direction, stopping at each end rather than wrapping. + * The next stop on the doubling ladder: `-1` coarser (twice the tolerance), `+1` + * finer (half of it), clamped to the range and stopping at each end rather than + * wrapping — `[` and `]` are held down, and a control that wrapped would take + * somebody from the coarsest straight to the finest. * - * Stopping is deliberate: `[` and `]` are held down, and a control that wrapped - * would take somebody from the coarsest straight to the finest without their - * having asked for anything in between. + * The ladder is the powers of two between the ends. A `tolerance` the slider + * left between stops is taken to the nearest one first, so a bracket always + * lands back on the ladder rather than walking off it from wherever the slider + * happened to leave it. */ -export function steppedDetail(detail: Detail, direction: -1 | 1): Detail { - const at = DETAIL_STEPS.indexOf(detail); - const next = Math.min(DETAIL_STEPS.length - 1, Math.max(0, at + direction)); - return DETAIL_STEPS[next]!; +export function steppedTolerance(tolerance: number, direction: -1 | 1): number { + const exponent = Math.round(Math.log2(tolerance)) + (direction === -1 ? 1 : -1); + return Math.min(MAXIMUM_TOLERANCE, Math.max(MINIMUM_TOLERANCE, 2 ** exponent)); } diff --git a/frontend/annotator/src/core/input/actions.ts b/frontend/annotator/src/core/input/actions.ts index 4fdfc741..7ae1a157 100644 --- a/frontend/annotator/src/core/input/actions.ts +++ b/frontend/annotator/src/core/input/actions.ts @@ -253,7 +253,7 @@ export const ACCEPT_SUGGESTION = "accept-suggestion"; export const DISCARD_SUGGESTION = "discard-suggestion"; /** - * Move the suggestion's vertex density one step — `[` coarser, `]` finer. + * Move the suggestion's tolerance one stop — `[` coarser (doubles it), `]` finer (halves it). * * Ordinary rows rather than a substitution, because unlike `enter` and `escape` * these two chords mean nothing else: there is no drawing gesture and no @@ -262,11 +262,11 @@ export const DISCARD_SUGGESTION = "discard-suggestion"; * * Host rows for `TOGGLE_SUGGEST`'s reason with one addition of its own. The * *arithmetic* is core's — `geometry/simplify.ts`, held to the kernel's answers - * by a golden fixture — but which step the session is on is part of a session - * the host holds, and the host is also what decides whether the control is - * offered at all: the server declares whether `detail` applies to the kind of - * shape this class holds, and a bracket pressed on a box class answers `false` - * and falls through. + * by a golden fixture — but which tolerance the session holds is part of a + * session the host holds, and the host is also what decides whether the control + * is offered at all: the server declares whether the tolerance applies to the + * kind of shape this class holds, and a bracket pressed on a box class answers + * `false` and falls through. * * Two rows and not one signed row, because a chord is bound to an action name * and `[` and `]` are two chords. It is also what lets a host offer one and diff --git a/frontend/annotator/src/core/interaction/suggestion.test.ts b/frontend/annotator/src/core/interaction/suggestion.test.ts index e3b07648..538e313a 100644 --- a/frontend/annotator/src/core/interaction/suggestion.test.ts +++ b/frontend/annotator/src/core/interaction/suggestion.test.ts @@ -15,12 +15,11 @@ import { AnnotatorStore } from "../state/store"; import { annotationsInDrawOrder, createDocument } from "../state/document"; import type { AnnotationDocument } from "../state/document"; import { addAnnotationCommand } from "../state/commands"; -import { DETAIL_STEPS, polygonAt } from "../geometry/simplify"; import type { AnnotationSchema, AssetDescriptor, Geometry, LabelClass, Point } from "../types"; import type { Answer } from "./suggestion"; import { vertexCount, - withDetail, + withTolerance, SUGGESTIBLE_GEOMETRY_TYPES, acceptedAnnotations, allowedGeometriesFor, @@ -104,7 +103,7 @@ function answerOf(...suggestions: readonly Suggestion[]): Answer { modelRef: MODEL_REF, confidence: suggestions[0]?.confidence ?? null, suggestions, - parameters: ["detail"], + parameters: ["tolerance"], }; } @@ -500,7 +499,7 @@ describe("nothing about a pending suggestion is in the document or the history", }); -describe("adjusting the vertex density", () => { +describe("adjusting the tolerance", () => { /** * A circle's traced ring, at integer pixels. * @@ -517,40 +516,41 @@ describe("adjusting the vertex density", () => { return showing(proposal({ type: "polygon", points: [...RING] }, 0.9, RING)); } + /** The eight corners of a four-pixel disc: a shape the coarse end cannot keep. */ + const SMALL: readonly Point[] = [[2, 0], [4, 0], [6, 2], [6, 4], [4, 6], [2, 6], [0, 4], [0, 2]]; + + function smallShown(): SuggestionState { + return showing(proposal({ type: "polygon", points: [...SMALL] }, 0.9, SMALL)); + } + it("re-simplifies here, with no ask and no new serial", () => { - // The whole reason the contour travels: `[` and `]` are held down, and a - // request per keypress would put a network round trip and a model decode - // between the press and the picture. const before = withContour(); - const after = withDetail(before, "coarse"); + const after = withTolerance(before, 4); expect(after.serial).toBe(before.serial); expect(after.status).toBe("shown"); - expect(after.adjustments.detail).toBe("coarse"); + expect(after.adjustments.tolerance).toBe(4); }); it("keeps fewer vertices the coarser it is asked to be", () => { - const fine = withDetail(withContour(), "fine"); - const coarse = withDetail(withContour(), "coarse"); + const fine = withTolerance(withContour(), 0.5); + const coarse = withTolerance(withContour(), 8); expect(vertexCount(coarse)).toBeLessThan(vertexCount(fine)); }); - it("returns the state by identity for the step already set", () => { - // So a host can fold it through unconditionally without a render. + it("returns the state by identity for the tolerance already set", () => { const state = withContour(); - expect(withDetail(state, state.adjustments.detail)).toBe(state); + expect(withTolerance(state, state.adjustments.tolerance)).toBe(state); }); it("leaves a box exactly as it is, because it was reduced from nothing", () => { - // The same fact the server states by leaving `detail` out of `parameters` - // for a box class, seen from the client's side. const boxed = showing(proposal(A_BOX, 0.9, [])); - expect(withDetail(boxed, "coarse").suggestions[0]?.geometry).toEqual(A_BOX); + expect(withTolerance(boxed, 8).suggestions[0]?.geometry).toEqual(A_BOX); }); - it("records the step even with nothing showing, so the next ask carries it", () => { + it("records the tolerance even with nothing showing, so the next ask carries it", () => { const armedOnly = armed("car"); - expect(withDetail(armedOnly, "fine").adjustments.detail).toBe("fine"); - expect(withDetail(armedOnly, "fine").status).toBe("idle"); + expect(withTolerance(armedOnly, 0.5).adjustments.tolerance).toBe(0.5); + expect(withTolerance(armedOnly, 0.5).status).toBe("idle"); }); it("counts only the vertices of the polygons it is drawing", () => { @@ -559,18 +559,31 @@ describe("adjusting the vertex density", () => { expect(vertexCount(withContour())).toBe(vertexCount(withContour())); }); - it("a step can never lose a shape, because only a zero-area outline is refused", () => { - // Reported in planning as a defect — a coarser step dropping a shape and - // taking its contour with it, so a finer step could not bring it back. It is - // not reachable, and this is the measurement rather than the argument: - // `polygonAt` refuses only a contour with no area, and whether points are - // collinear does not depend on the tolerance. So the three steps agree about - // which shapes exist and differ only in how many vertices each spends (#557). - const collinear: readonly Point[] = [[0, 0], [5, 0], [10, 0]]; - const curved = RING; - for (const step of DETAIL_STEPS) { - expect(polygonAt(collinear, step)).toBeNull(); - expect(polygonAt(curved, step)).not.toBeNull(); + it("a coarse tolerance can leave a small shape with nothing", () => { + // A shape survives while three of its contour points are still more than the + // tolerance apart. + expect(withTolerance(smallShown(), 16).status).toBe("none"); + }); + + it("brings the small shape back when the tolerance goes fine again", () => { + const lost = withTolerance(smallShown(), 16); + const found = withTolerance(lost, 1); + expect(found.status).toBe("shown"); + expect(found.suggestions[0]?.geometry.type).toBe("polygon"); + expect(vertexCount(found)).toBeGreaterThanOrEqual(3); + }); + + it("counts no vertices while there is nothing to show", () => { + expect(vertexCount(withTolerance(smallShown(), 16))).toBe(0); + }); + + it("leaves an empty answer empty at every tolerance, because it has no contour to reduce", () => { + const asked = withPoint(armed("car"), [1, 1], "positive"); + const empty = answered(asked, asked.serial, NOTHING); + for (const tolerance of [0.25, 4, 16]) { + const moved = withTolerance(empty, tolerance); + expect(moved.status).toBe("none"); + expect(moved.suggestions).toEqual([]); } }); }); @@ -597,9 +610,9 @@ describe("what the answer declares", () => { modelRef: MODEL_REF, confidence: null, suggestions: [], - parameters: ["detail"], + parameters: ["tolerance"], }); expect(empty.status).toBe("none"); - expect(empty.parameters).toEqual(["detail"]); + expect(empty.parameters).toEqual(["tolerance"]); }); }); diff --git a/frontend/annotator/src/core/interaction/suggestion.ts b/frontend/annotator/src/core/interaction/suggestion.ts index 183149d0..2a32d154 100644 --- a/frontend/annotator/src/core/interaction/suggestion.ts +++ b/frontend/annotator/src/core/interaction/suggestion.ts @@ -89,8 +89,7 @@ * that belongs to a class nobody is on any more. */ -import { polygonAt } from "../geometry/simplify"; -import type { Detail } from "../geometry/simplify"; +import { DEFAULT_TOLERANCE, polygonAt } from "../geometry/simplify"; import { classNamed } from "../state/document"; import type { AnnotationDocument } from "../state/document"; import type { IdFactory } from "../ids"; @@ -229,7 +228,7 @@ export interface Suggestion { /** * The outline this shape was reduced from, empty for a box. * - * What makes {@link withDetail} arithmetic rather than a round trip. It is the + * What makes {@link withTolerance} arithmetic rather than a round trip. It is the * *same* points the server reduced, which matters because Douglas-Peucker is * not nested: a client starting from anything else could not be held to the * server's answer, and the server is what finally writes. @@ -246,7 +245,7 @@ export interface Suggestion { * value — it is carried to whoever asks whether a given setting applies — so a * member this build cannot name costs a control nobody can render, and no more. */ -export type SuggestParameter = "detail" | (string & {}); +export type SuggestParameter = "tolerance" | (string & {}); /** What an answer carries back, beside the shapes themselves. */ export interface Answer { @@ -257,7 +256,7 @@ export interface Answer { * Which settings the server says apply to the kind of shape this class holds. * * Read from the answer and never computed here. A client that worked out for - * itself that a box has no use for `detail` would be the second copy of a rule + * itself that a box has no use for the tolerance would be the second copy of a rule * the kernel already owns, free to drift the first time the rule changes. */ readonly parameters: readonly SuggestParameter[]; @@ -280,19 +279,19 @@ export type SuggestionStatus = "idle" | "asking" | "shown" | "none" | "refused"; /** The one setting, as it stands right now. Sent on every ask. */ export interface Adjustments { - readonly detail: Detail; + /** How far, in asset pixels, the outline may stray from the mask. */ + readonly tolerance: number; } /** * What a session starts with, and what the server means by "nothing was sent". * - * The value is the kernel's own default. It is restated here because this package - * has no HTTP and cannot read it from an answer that has not arrived — and - * `simplify.test.ts` holds `EPSILON` to the kernel's table, which is the half - * that could silently differ. + * The value is the kernel's own default, restated here because this package has + * no HTTP and cannot read it from an answer that has not arrived; the fixture + * holds the two to each other. */ export const DEFAULT_ADJUSTMENTS: Adjustments = { - detail: "balanced", + tolerance: DEFAULT_TOLERANCE, }; /** The whole of a suggest session. `null`, in a host, is a tool that is not armed. */ @@ -480,50 +479,45 @@ export function isAcceptable(state: SuggestionState): boolean { } /** - * A different vertex density, applied here and now. + * A different tolerance, applied here and now. * * **No request.** Each shape carries the contour it was reduced from, so this is * arithmetic — which is what lets `[` and `]` be held down. The server stays * authoritative: accepting asks again with these settings and writes what comes - * back, and `tests/fixtures/simplification.json` is what holds the two to the - * same answer. - * - * A shape with no contour is left exactly as it is. That is a box, and `detail` - * has nothing to do to one — the same fact the server states by leaving `detail` - * out of `parameters` for a box class. - * - * A step that is already set returns the state **by identity**, so a host can - * fold this through unconditionally without a render. - * - * **A step can never lose a shape, and that was measured rather than assumed.** - * `polygonAt` answers `null` only for a contour with no area — three or more - * collinear points — and collinearity is a property of the contour rather than of - * the tolerance, so a shape drawable at one step is drawable at all three. The - * empty branch below is the type's, not a reachable state: a zero-area outline is - * dropped by the server before it is ever sent (#557). + * back, and `tests/fixtures/simplification.json` holds the two to the same answer. + * + * A shape with no contour is left exactly as it is. That is a box, and the + * tolerance has nothing to do to one — the same fact the server states by + * leaving it out of `parameters` for a box class. + * + * **A coarse tolerance can leave a small shape with nothing, and that is + * reversible.** The suggestions are kept when none of them survives, so a finer + * tolerance re-derives the shape from the same contour; what the readers see is + * gated on the status instead — {@link vertexCount} counts nothing and the + * painter draws nothing while `none`. + * + * A tolerance that is already set returns the state **by identity**, so a host + * can fold this through unconditionally without a render. */ -export function withDetail(state: SuggestionState, detail: Detail): SuggestionState { - if (detail === state.adjustments.detail) return state; - const adjustments = { ...state.adjustments, detail }; - if (state.status !== "shown") return { ...state, adjustments }; - const suggestions = state.suggestions.flatMap((one) => resimplified(one, detail) ?? []); - return { - ...state, - adjustments, - suggestions, - status: suggestions.length === 0 ? "none" : "shown", - }; +export function withTolerance(state: SuggestionState, tolerance: number): SuggestionState { + if (tolerance === state.adjustments.tolerance) return state; + const adjustments = { ...state.adjustments, tolerance }; + if (state.status !== "shown" && state.status !== "none") return { ...state, adjustments }; + const suggestions = state.suggestions.flatMap((one) => resimplified(one, tolerance) ?? []); + if (suggestions.length === 0) return { ...state, adjustments, status: "none" }; + return { ...state, adjustments, suggestions, status: "shown" }; } -function resimplified(one: Suggestion, detail: Detail): Suggestion | null { +function resimplified(one: Suggestion, tolerance: number): Suggestion | null { if (one.contour.length === 0) return one; - const points = polygonAt(one.contour, detail); + const points = polygonAt(one.contour, tolerance); if (points === null) return null; return { ...one, geometry: { ...one.geometry, type: "polygon", points } as Geometry }; } /** How many vertices the preview is currently spending. What the counter reads. */ export function vertexCount(state: SuggestionState): number { + if (state.status === "none") return 0; return state.suggestions.reduce( (total, one) => total + (one.geometry.type === "polygon" ? one.geometry.points.length : 0), 0, diff --git a/frontend/annotator/src/index.ts b/frontend/annotator/src/index.ts index 2c93d6ca..eaae5ca2 100644 --- a/frontend/annotator/src/index.ts +++ b/frontend/annotator/src/index.ts @@ -169,7 +169,7 @@ export { suggestibleClassIn, vertexCount, withClass, - withDetail, + withTolerance, withPoint, type Adjustments, type Answer, @@ -183,19 +183,17 @@ export { type SuggestionStatus, } from "./core/interaction/suggestion"; -// The simplifier the editor re-runs when `detail` moves, and the vocabulary it +// The simplifier the editor re-runs when the tolerance moves, and the range it // moves through. Exported because `ui-core` renders the control and reads the // vertex count; the algorithm itself is held to the kernel's answers by // `tests/fixtures/simplification.json`. export { - DETAIL_STEPS, - EPSILON, + DEFAULT_TOLERANCE, + MAXIMUM_TOLERANCE, MINIMUM_TOLERANCE, polygonAt, simplified, - steppedDetail, - toleranceFor, - type Detail, + steppedTolerance, } from "./core/geometry/simplify"; export { TRANSITIONS, From ab620a2e8b7937e82b124e50d26af90deb57a859 Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Tue, 25 Aug 2026 07:30:41 -0700 Subject: [PATCH 5/7] feat(ui): the detail slider is a pixel tolerance on a doubling track The slider's position is log2 of the tolerance between 0.25 and 16 px, the value is rounded at the callback, and the label reads the tolerance and the vertex count. The request carries tolerance and reads applied.tolerance back. --- .../ui-core/src/annotator/AnnotationPage.tsx | 76 +++++++++---------- .../ui-core/src/annotator/SuggestPanel.tsx | 61 +++++++-------- .../src/annotator/suggestFlow.test.tsx | 4 +- .../src/annotator/suggestPanel.test.tsx | 65 +++++++++------- frontend/ui-core/src/data/inferenceQueries.ts | 13 ++-- 5 files changed, 112 insertions(+), 107 deletions(-) diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx index c0808e6e..1bb34d4f 100644 --- a/frontend/ui-core/src/annotator/AnnotationPage.tsx +++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx @@ -108,7 +108,7 @@ import { refused, selectOnly, selectionOf, - steppedDetail, + steppedTolerance, suggestClassFor, suggestGeometriesFor, suggestibleClassIn, @@ -116,13 +116,12 @@ import { useAnnotatorSnapshot, usePendingIndicator, withClass, - withDetail, withPoint, + withTolerance, type Answer, type AnnotatorStore, type AnnotatorView, type Clipboard, - type Detail, type Point, type Polarity, type Suggestion, @@ -583,21 +582,17 @@ function JobScreen({ const [activeTool, setActiveTool] = useState(null); /** - * The suggest tool's vertex density, held here for `activeClass`'s reason. + * The suggest tool's tolerance, held here for `activeClass`'s reason. * * A choice about how to work rather than a fact about the workspace, so it is * client memory and never a write: two people annotating the same batch may - * reasonably want different amounts of detail, and a shared setting would make - * one of them keep changing the other's. It is also not `prefs.ts` — that tier - * is a preference remembered across visits, and this is a session, in the same + * reasonably want different tolerances, and a shared setting would make one of + * them keep changing the other's. It is also not `prefs.ts` — that tier is a + * preference remembered across visits, and this is a session, in the same * scope the clipboard and the drawing class have. Leaving the job forgets it, * moving to the next frame does not. - * - * The other two settings deliberately do not live here. They change the mask - * rather than the reading of it, so carrying one to the next frame would mean - * quietly asking a different question about a different picture. */ - const [detail, setDetail] = useState(DEFAULT_ADJUSTMENTS.detail); + const [tolerance, setTolerance] = useState(DEFAULT_ADJUSTMENTS.tolerance); /** * Every route to a drawing class goes through here — the panel's list, the tool * strip, a digit hotkey and the canvas's own `activate-class`. @@ -675,8 +670,8 @@ function JobScreen({ clipboard={clipboard} activeClass={activeClass} activeTool={activeTool} - detail={detail} - onDetail={setDetail} + tolerance={tolerance} + onTolerance={setTolerance} onActivateClass={activateClass} onActivateTool={setActiveTool} onNavigate={setChosen} @@ -752,9 +747,9 @@ interface WorkspaceProps { } | null; /** Held by `JobScreen`, so `mod+c` here and `mod+v` on the next frame is one clipboard. */ readonly clipboard: Clipboard; - /** The suggest tool's vertex density, held one level up so it outlives a frame. */ - readonly detail: Detail; - readonly onDetail: (detail: Detail) => void; + /** The suggest tool's tolerance, held one level up so it outlives a frame. */ + readonly tolerance: number; + readonly onTolerance: (tolerance: number) => void; /** Also `JobScreen`'s, and for a sharper reason — see the note where it is declared. */ readonly activeClass: string | null; /** `JobScreen`'s too, and at that scope for the same reason the class is. */ @@ -804,8 +799,8 @@ function Workspace({ loaded, counts, clipboard, - detail, - onDetail: setDetail, + tolerance, + onTolerance: setTolerance, activeClass, activeTool, onActivateClass: armClass, @@ -1038,9 +1033,9 @@ function Workspace({ const labelClass = suggestClassFor(store.document.schema, activeClass); if (labelClass === null) return; activateClass(labelClass); - // The vertex density the job is already working at, so arming the tool on - // the next frame does not quietly go back to the middle setting. - setSession(armed(labelClass, { ...DEFAULT_ADJUSTMENTS, detail })); + // The tolerance the job is already working at, so arming the tool on the + // next frame does not quietly go back to the default. + setSession(armed(labelClass, { ...DEFAULT_ADJUSTMENTS, tolerance })); } /** @@ -1116,26 +1111,25 @@ function Workspace({ setSession(cleared(session)); } - /** A step of vertex density, applied here: no request, so a held key is free. */ - function applyDetail(next: Detail): void { + /** A tolerance applied here: no request, so a held key is free. */ + function applyTolerance(next: number): void { if (session === null) return; - setSession(withDetail(session, next)); - // Lifted, so the choice outlives this frame. See `JobScreen`'s own note. - setDetail(next); + setSession(withTolerance(session, next)); + setTolerance(next); } /** * `[` and `]`, answering `false` where there is nothing for them to move. * - * The declaration decides, not this file: a box class never has `detail` in - * `parameters`, so the bracket falls through to the browser rather than being - * swallowed by a control that is not on screen. + * The declaration decides, not this file: a box class never has the tolerance + * in `parameters`, so the bracket falls through to the browser rather than + * being swallowed by a control that is not on screen. */ - function stepDetail(direction: -1 | 1): boolean { - if (session === null || !session.parameters.includes("detail")) return false; - const next = steppedDetail(session.adjustments.detail, direction); - if (next === session.adjustments.detail) return false; - applyDetail(next); + function stepTolerance(direction: -1 | 1): boolean { + if (session === null || !session.parameters.includes("tolerance")) return false; + const next = steppedTolerance(session.adjustments.tolerance, direction); + if (next === session.adjustments.tolerance) return false; + applyTolerance(next); return true; } @@ -1263,11 +1257,11 @@ function Workspace({ return true; } // The brackets answer `false` when there is no session, or when the server - // has not declared `detail` as applying here — a box class — so the chord - // falls through to the browser rather than being swallowed by a control that - // is not on screen. - if (name === COARSER_SUGGESTION) return stepDetail(-1); - if (name === FINER_SUGGESTION) return stepDetail(1); + // has not declared the tolerance as applying here — a box class — so the + // chord falls through to the browser rather than being swallowed by a + // control that is not on screen. + if (name === COARSER_SUGGESTION) return stepTolerance(-1); + if (name === FINER_SUGGESTION) return stepTolerance(1); return false; } @@ -2782,7 +2776,7 @@ function Workspace({ onDiscard={discardSuggestion} adjusting={adjusting} onAdjusting={setAdjusting} - onDetail={applyDetail} + onTolerance={applyTolerance} // Off the same clock the halo is drawn from, which is what lets // the card and the canvas be read as one report of one wait // rather than as two. The card's own appearance follows the diff --git a/frontend/ui-core/src/annotator/SuggestPanel.tsx b/frontend/ui-core/src/annotator/SuggestPanel.tsx index 83e280ba..d2023455 100644 --- a/frontend/ui-core/src/annotator/SuggestPanel.tsx +++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx @@ -55,13 +55,13 @@ */ import { - DETAIL_STEPS, + MAXIMUM_TOLERANCE, + MINIMUM_TOLERANCE, vertexCount, isAcceptable, isParked, hasPending, type SuggestionState, - type Detail, } from "@visionset/annotator"; import { Check, Loader2, Sparkles, TriangleAlert, X } from "lucide-react"; import type { JSX, ReactNode } from "react"; @@ -125,8 +125,8 @@ export interface SuggestPanelProps { /** Whether the adjustments are open. Owned by the host, because `Esc` layers on it. */ readonly adjusting?: boolean; readonly onAdjusting?: (open: boolean) => void; - /** A step of vertex density, applied without a request. */ - readonly onDetail?: (detail: Detail) => void; + /** A tolerance, applied without a request. */ + readonly onTolerance?: (tolerance: number) => void; /** * Whether the wait has lasted long enough to be worth explaining. * @@ -206,7 +206,7 @@ export function SuggestPanel({ onDiscard, adjusting, onAdjusting, - onDetail, + onTolerance, pendingEscalated = false, }: SuggestPanelProps): JSX.Element { /* @@ -348,7 +348,7 @@ export function SuggestPanel({ session={session} open={adjusting === true} {...(onAdjusting === undefined ? {} : { onOpen: onAdjusting })} - {...(onDetail === undefined ? {} : { onDetail })} + {...(onTolerance === undefined ? {} : { onTolerance })} /> ); @@ -391,7 +391,7 @@ export function SuggestPanel({ session={session} open={adjusting === true} {...(onAdjusting === undefined ? {} : { onOpen: onAdjusting })} - {...(onDetail === undefined ? {} : { onDetail })} + {...(onTolerance === undefined ? {} : { onTolerance })} /> ); @@ -518,7 +518,7 @@ function Chip({ children }: { readonly children: ReactNode }): JSX.Element { * below. */ function hasAdjustments(session: SuggestionState): boolean { - return session.parameters.includes("detail"); + return session.parameters.includes("tolerance"); } /** @@ -582,12 +582,12 @@ function Adjustments({ session, open, onOpen, - onDetail, + onTolerance, }: { readonly session: SuggestionState; readonly open: boolean; readonly onOpen?: (open: boolean) => void; - readonly onDetail?: (detail: Detail) => void; + readonly onTolerance?: (tolerance: number) => void; }): JSX.Element | null { // Gated on a setting this build has a row for, not on the list being non-empty: // a length test would offer `Adjust the shape` over an empty box the first time a @@ -608,22 +608,21 @@ function Adjustments({ ); } - const { detail } = session.adjustments; - const step = DETAIL_STEPS.indexOf(detail); + const { tolerance } = session.adjustments; return (
- {session.parameters.includes("detail") && onDetail !== undefined && ( + {session.parameters.includes("tolerance") && onTolerance !== undefined && (
Detail
{/* - A slider, because three words in a row did not read as pressable and - gave no feedback about what had moved (#557). Three stops rather than - a continuous range: `Detail` is three steps, and a range that landed - between them would be a position the server has no answer for. + A doubling track: the thumb's position is log2 of the tolerance, so + each halving takes the same distance and the fine end of the range + is not squeezed into one pixel of travel. Quarter steps of the + exponent give a continuous feel; the brackets walk whole doublings. A native `input[type=range]` and not a primitive, because there is no slider primitive in this package and one control does not earn one. @@ -632,12 +631,12 @@ function Adjustments({ onDetail(DETAIL_STEPS[Number(event.target.value)] ?? detail)} + onChange={(event) => + onTolerance(Math.round(2 ** Number(event.target.value) * 100) / 100) + } /> {/* - Step and count in one label, because they are one fact: what this - position costs. Tabular figures so the number does not shift the row - as it changes under a held key. + Tolerance and count in one label, because they are one fact: what + this position costs. Tabular figures so the number does not shift + the row as it changes under a held key. */} - {labelFor(detail)} · {vertexCount(session)} pts + {px(tolerance)} px · {vertexCount(session)} pts [ ]
@@ -666,7 +667,7 @@ function Adjustments({ ); } -/** Sentence case, from the wire's own lowercase vocabulary. */ -function labelFor(detail: Detail): string { - return detail.charAt(0).toUpperCase() + detail.slice(1); +/** `1.0`, `0.5`, `0.25` — one decimal for whole pixels, the exact fraction below. */ +function px(tolerance: number): string { + return Number.isInteger(tolerance) ? tolerance.toFixed(1) : String(tolerance); } diff --git a/frontend/ui-core/src/annotator/suggestFlow.test.tsx b/frontend/ui-core/src/annotator/suggestFlow.test.tsx index eade67dd..d4c4706e 100644 --- a/frontend/ui-core/src/annotator/suggestFlow.test.tsx +++ b/frontend/ui-core/src/annotator/suggestFlow.test.tsx @@ -230,9 +230,9 @@ beforeEach(() => { contour: [], }, ], - applied: { detail: "balanced" }, + applied: { tolerance: 1 }, // Declared as the server declares it for a box class: nothing at all, so the - // panel renders no adjustments. It works none of that out for itself (#557). + // panel renders no adjustments. It works none of that out for itself. parameters: [], }; suggestRefusal = null; diff --git a/frontend/ui-core/src/annotator/suggestPanel.test.tsx b/frontend/ui-core/src/annotator/suggestPanel.test.tsx index 75fd9230..070de471 100644 --- a/frontend/ui-core/src/annotator/suggestPanel.test.tsx +++ b/frontend/ui-core/src/annotator/suggestPanel.test.tsx @@ -35,7 +35,7 @@ function answerOf(...suggestions: readonly Suggestion[]): Answer { modelRef: MODEL_REF, confidence: suggestions[0]?.confidence ?? null, suggestions, - parameters: ["detail"], + parameters: ["tolerance"], }; } @@ -457,7 +457,7 @@ describe("the adjustments, which are a section and never a popup", () => { contour: [[0, 0], [10, 0], [10, 10], [0, 10]], }, ], - parameters: ["detail"], + parameters: ["tolerance"], }; } @@ -474,7 +474,7 @@ describe("the adjustments, which are a section and never a popup", () => { it("renders exactly the parameters the server declared, and nothing else", () => { render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), - onDetail: vi.fn() })); + onTolerance: vi.fn() })); expect(screen.getByTestId("suggest-detail")).toBeTruthy(); // The two that were here and are not (#557). A control wired to nothing on // the ordinary mask is worse than no control. @@ -484,10 +484,10 @@ describe("the adjustments, which are a section and never a popup", () => { it("offers a box class no section at all, because the wire declared nothing", () => { // The whole of the rule: no condition in this file mentions a box. Declare - // `detail` for a box in the kernel's table and this test goes red there. + // `tolerance` for a box in the kernel's table and this test goes red there. const session = asked(); const boxy = answered(session, session.serial, { ...polygonAnswer(), parameters: [] }); - render(mount({ session: boxy, adjusting: true, onAdjusting: vi.fn(), onDetail: vi.fn() })); + render(mount({ session: boxy, adjusting: true, onAdjusting: vi.fn(), onTolerance: vi.fn() })); expect(screen.queryByTestId("suggest-adjustments")).toBeNull(); expect(screen.queryByTestId("suggest-adjust-open")).toBeNull(); @@ -514,7 +514,7 @@ describe("the adjustments, which are a section and never a popup", () => { parameters: ["depth_bias"], }); render( - mount({ session: unknown, adjusting: true, onAdjusting: vi.fn(), onDetail: vi.fn() }), + mount({ session: unknown, adjusting: true, onAdjusting: vi.fn(), onTolerance: vi.fn() }), ); expect(screen.queryByTestId("suggest-adjust-open")).toBeNull(); @@ -528,37 +528,48 @@ describe("the adjustments, which are a section and never a popup", () => { const session = asked(); const mixed = answered(session, session.serial, { ...polygonAnswer(), - parameters: ["depth_bias", "detail"], + parameters: ["depth_bias", "tolerance"], }); - render(mount({ session: mixed, adjusting: true, onAdjusting: vi.fn(), onDetail: vi.fn() })); + render(mount({ session: mixed, adjusting: true, onAdjusting: vi.fn(), onTolerance: vi.fn() })); expect(screen.getByTestId("suggest-adjustments")).toBeTruthy(); expect(screen.getByTestId("suggest-detail")).toBeTruthy(); }); - it("names the step and what it costs in one label, beside the control", () => { + it("names the tolerance and what it costs in one label, beside the control", () => { render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), - onDetail: vi.fn() })); - expect(screen.getByTestId("suggest-detail-label").textContent).toBe("Balanced · 4 pts"); + onTolerance: vi.fn() })); + expect(screen.getByTestId("suggest-detail-label").textContent).toBe("1.0 px · 4 pts"); }); - it("puts the slider on the step the session is holding", () => { + it("puts the slider on a doubling track, at the tolerance the session is holding", () => { render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), - onDetail: vi.fn() })); + onTolerance: vi.fn() })); const slider = screen.getByTestId("suggest-detail") as HTMLInputElement; - expect(slider.value).toBe("1"); - expect(slider.min).toBe("0"); - expect(slider.max).toBe("2"); + expect(slider.value).toBe("0"); + expect(slider.min).toBe("-2"); + expect(slider.max).toBe("4"); + expect(slider.step).toBe("0.25"); }); - it("reports a step through the door that needs no request", () => { - const onDetail = vi.fn(); - render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), onDetail })); + it("reports a tolerance through the door that needs no request", () => { + const onTolerance = vi.fn(); + render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), onTolerance })); - fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "0" } }); - expect(onDetail).toHaveBeenCalledWith("coarse"); - fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "2" } }); - expect(onDetail).toHaveBeenCalledWith("fine"); + fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "4" } }); + expect(onTolerance).toHaveBeenCalledWith(16); + fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "-2" } }); + expect(onTolerance).toHaveBeenCalledWith(0.25); + fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "-1" } }); + expect(onTolerance).toHaveBeenCalledWith(0.5); + }); + + it("rounds a quarter step's tolerance to two decimals", () => { + const onTolerance = vi.fn(); + render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), onTolerance })); + + fireEvent.change(screen.getByTestId("suggest-detail"), { target: { value: "0.25" } }); + expect(onTolerance).toHaveBeenCalledWith(1.19); }); it("lets a press on the slider through, because that press is the drag", () => { @@ -567,7 +578,7 @@ describe("the adjustments, which are a section and never a popup", () => { // moved with the brackets. The old test asserted the guard *fired*, which is // exactly the assertion a dead control passes (#563). render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), - onDetail: vi.fn() })); + onTolerance: vi.fn() })); const press = fireEvent.mouseDown(screen.getByTestId("suggest-detail")); expect(press).toBe(true); }); @@ -581,7 +592,7 @@ describe("the adjustments, which are a section and never a popup", () => { document.body.appendChild(root); render(mount({ session: showingPolygon(), adjusting: true, onAdjusting: vi.fn(), - onDetail: vi.fn() })); + onTolerance: vi.fn() })); const slider = screen.getByTestId("suggest-detail"); slider.focus(); expect(document.activeElement).toBe(slider); @@ -601,7 +612,7 @@ describe("the adjustments, which are a section and never a popup", () => { ...polygonAnswer(), suggestions: [], }); - render(mount({ session: empty, adjusting: true, onAdjusting: vi.fn(), onDetail: vi.fn() })); + render(mount({ session: empty, adjusting: true, onAdjusting: vi.fn(), onTolerance: vi.fn() })); expect(screen.getByTestId("suggest-none")).toBeTruthy(); expect(screen.getByTestId("suggest-adjustments")).toBeTruthy(); @@ -619,7 +630,7 @@ describe("the adjustments, which are a section and never a popup", () => { suggestions: [], parameters: ["depth_bias"], }); - render(mount({ session: empty, adjusting: true, onAdjusting: vi.fn(), onDetail: vi.fn() })); + render(mount({ session: empty, adjusting: true, onAdjusting: vi.fn(), onTolerance: vi.fn() })); expect(screen.getByTestId("suggest-none")).toBeTruthy(); expect(screen.queryByTestId("suggest-adjustments")).toBeNull(); diff --git a/frontend/ui-core/src/data/inferenceQueries.ts b/frontend/ui-core/src/data/inferenceQueries.ts index d7bc1da1..bdce5ce9 100644 --- a/frontend/ui-core/src/data/inferenceQueries.ts +++ b/frontend/ui-core/src/data/inferenceQueries.ts @@ -45,7 +45,6 @@ import type { Adjustments, - Detail, GeometryType, SuggestParameter, } from "@visionset/annotator"; @@ -115,15 +114,15 @@ export interface SuggestionOut { readonly confidence: number; readonly regions: readonly SuggestedRegion[]; readonly applied: { - readonly detail: Detail; + readonly tolerance: number; }; /** * Which settings have any effect on the kind of shape this class holds. * * Read and rendered as given. The editor works none of it out for itself — - * that a box has no use for `detail` is the kernel's rule, and a second copy - * of it here would be the hand-mirrored table the capabilities contract exists - * to forbid. + * that a box has no use for the tolerance is the kernel's rule, and a second + * copy of it here would be the hand-mirrored table the capabilities contract + * exists to forbid. */ readonly parameters: readonly SuggestParameter[]; } @@ -522,7 +521,7 @@ export interface SuggestInput { * `suggestGeometriesFor` is where that choice is made once. */ readonly allowedGeometries: readonly GeometryType[]; - /** Where the three settings stand. Sent on every ask, echoed by every answer. */ + /** Where the setting stands. Sent on every ask, echoed by every answer. */ readonly adjustments: Adjustments; } @@ -540,7 +539,7 @@ export function useSuggestRegion() { positive: input.positive.map(([x, y]) => ({ x, y })), negative: input.negative.map(([x, y]) => ({ x, y })), allowed_geometries: [...input.allowedGeometries], - detail: input.adjustments.detail, + tolerance: input.adjustments.tolerance, } as never, }), checkSuggestRegion, From 2832cd1cc543acde486a23a028fccbc48ca52c46 Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Tue, 25 Aug 2026 07:30:41 -0700 Subject: [PATCH 6/7] test(e2e): the suggestion tolerance walks its ladder from the keyboard and the slider --- frontend/app/e2e/annotate.spec.ts | 65 +++++++++++++++---------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/frontend/app/e2e/annotate.spec.ts b/frontend/app/e2e/annotate.spec.ts index 59ff78dd..a6012db3 100644 --- a/frontend/app/e2e/annotate.spec.ts +++ b/frontend/app/e2e/annotate.spec.ts @@ -427,7 +427,7 @@ async function serveApi( contour: [], }, ], - applied: { detail: "balanced" }, + applied: { tolerance: 1 }, // A box class, so the wire names no settings at all — which is how the // editor is told to render no adjustments section (#557). parameters: [], @@ -3215,7 +3215,7 @@ test("a suggest request that is out says so on the panel and nowhere else", asyn contour: [], }, ], - applied: { detail: "balanced" }, + applied: { tolerance: 1 }, parameters: [], } satisfies Wire["SuggestionOut"], }); @@ -3266,7 +3266,7 @@ test("escape takes the wait back while the request is still out", async ({ page model_ref: "m@1", confidence: 0.5, regions: [], - applied: { detail: "balanced" }, + applied: { tolerance: 1 }, parameters: [], } satisfies Wire["SuggestionOut"], }); @@ -3314,13 +3314,13 @@ test("a box class is offered no adjustments at all", async ({ page }) => { await expect(page.getByTestId("suggest-detail")).toHaveCount(0); }); -/** A traced ring big enough that the three steps genuinely differ. */ +/** A traced ring big enough that the tolerances genuinely differ. */ const RING = Array.from({ length: 64 }, (_, index) => { const angle = (index / 64) * 2 * Math.PI; return [Math.round(160 + 90 * Math.cos(angle)), Math.round(160 + 90 * Math.sin(angle))]; }) satisfies [number, number][]; -/** A polygon answer over that ring, with `detail` declared as the one setting. */ +/** A polygon answer over that ring, with the tolerance declared as the one setting. */ async function servePolygonSuggestion(page: Page): Promise { await page.route("**/inference/suggest", async (route) => route.fulfill({ @@ -3328,8 +3328,8 @@ async function servePolygonSuggestion(page: Page): Promise { model_ref: "facebook/sam2-hiera-base-plus@main", confidence: 0.9, regions: [{ geometry: { type: "polygon", points: RING }, contour: RING }], - applied: { detail: "balanced" }, - parameters: ["detail"], + applied: { tolerance: 1 }, + parameters: ["tolerance"], } satisfies Wire["SuggestionOut"], }), ); @@ -3344,7 +3344,7 @@ async function drawnVertices(page: Page): Promise { const asks = (sent: readonly Request[]): number => sent.filter((one) => one.url().includes("/inference/suggest")).length; -test("a polygon class steps its detail from the keyboard, with no request", async ({ page }) => { +test("a polygon class steps its tolerance from the keyboard, with no request", async ({ page }) => { const sent: Request[] = []; await openJob(page, sent, undefined, undefined, undefined, undefined, true); await servePolygonSuggestion(page); @@ -3360,7 +3360,6 @@ test("a polygon class steps its detail from the keyboard, with no request", asyn await page.keyboard.press("["); const coarse = await drawnVertices(page); - // The claim that only a real request log can settle: no round trip. expect(asks(sent)).toBe(before); await page.keyboard.press("]"); @@ -3369,25 +3368,25 @@ test("a polygon class steps its detail from the keyboard, with no request", asyn expect(fine).toBeGreaterThan(coarse); expect(asks(sent)).toBe(before); - // And it stops at the end rather than wrapping round to the coarsest. + // Down to the floor, and then one more: it stops rather than wrapping. await page.keyboard.press("]"); - expect(await drawnVertices(page)).toBe(fine); + const finest = await drawnVertices(page); + await page.keyboard.press("]"); + expect(await drawnVertices(page)).toBe(finest); + // Back up to 2 px: 0.25 → 0.5 → 1 → 2. + await page.keyboard.press("["); await page.keyboard.press("["); await page.keyboard.press("["); await page.getByTestId("suggest-adjust-open").click(); - await expect(page.getByTestId("suggest-detail-label")).toHaveText(`Coarse · ${coarse} pts`); - await expect(page.getByTestId("suggest-detail")).toHaveValue("0"); + await expect(page.getByTestId("suggest-detail-label")).toHaveText(`2.0 px · ${coarse} pts`); + await expect(page.getByTestId("suggest-detail")).toHaveValue("1"); - // Opening the section must not switch the keyboard off, which is what a control - // taking focus would silently do — and does, in a browser, where jsdom has no - // focus to move and would report this working. + // Opening the section must not switch the keyboard off. await page.keyboard.press("]"); - await expect(page.getByTestId("suggest-detail")).toHaveValue("1"); + await expect(page.getByTestId("suggest-detail")).toHaveValue("0"); - // Escape closes the adjustments and stops there: the points and the shape are - // both still on screen, and the second press is what takes them. await page.keyboard.press("Escape"); await expect(page.getByTestId("suggest-adjustments")).toHaveCount(0); await expect(page.getByTestId("suggestion-shape")).toBeVisible(); @@ -3409,14 +3408,14 @@ test("the preview draws its vertices, and a committed shape does not", async ({ const preview = page.getByTestId("suggestion-preview"); await expect(preview.getByTestId("suggestion-shape")).toBeVisible(); - // Dashed, and carrying one dot per vertex. Without the dots the detail control - // moves a number and nothing anybody can see (#557). + // Dashed, and carrying one dot per vertex. Without the dots the tolerance control + // moves a number and nothing anybody can see. await expect(preview.locator("polygon")).toHaveAttribute("stroke-dasharray", "10 6"); const drawn = await drawnVertices(page); expect(drawn).toBeGreaterThan(3); await expect(preview.locator("circle")).toHaveCount(drawn); - // The set follows the detail, with no request — the same fact the counter + // The set follows the tolerance, with no request — the same fact the counter // reports, read off the canvas instead. await page.keyboard.press("["); await expect(preview.locator("circle")).toHaveCount(await drawnVertices(page)); @@ -3444,7 +3443,7 @@ test("the detail slider moves under the pointer, and hands the keyboard back", a await page.getByTestId("suggest-adjust-open").click(); const slider = page.getByTestId("suggest-detail"); - await expect(slider).toHaveValue("1"); + await expect(slider).toHaveValue("0"); const before = asks(sent); // A real drag: press the thumb, travel, release. `fill()` and `click()` both @@ -3457,27 +3456,27 @@ test("the detail slider moves under the pointer, and hands the keyboard back", a await page.mouse.move(track.x + track.width - 1, track.y + track.height / 2, { steps: 8 }); await page.mouse.up(); - await expect(slider).toHaveValue("2"); - await expect(page.getByTestId("suggest-detail-label")).toContainText("Fine"); - const fine = await drawnVertices(page); + await expect(slider).toHaveValue("4"); + await expect(page.getByTestId("suggest-detail-label")).toContainText("16.0 px"); + const coarse = await drawnVertices(page); // Still no round trip: the drag is arithmetic, like the brackets. expect(asks(sent)).toBe(before); - // Dragging the other way, to the coarsest stop. Two *client* simplifications + // Dragging the other way, to the finest stop. Two *client* simplifications // compared against each other — the answer's own geometry arrives already - // reduced by the server and is not one of the three steps. + // reduced by the server and is not at either end of the track. await page.mouse.move(track.x + track.width / 2, track.y + track.height / 2); await page.mouse.down(); await page.mouse.move(track.x + 1, track.y + track.height / 2, { steps: 8 }); await page.mouse.up(); - await expect(slider).toHaveValue("0"); - await expect(page.getByTestId("suggest-detail-label")).toContainText("Coarse"); - expect(fine).toBeGreaterThan(await drawnVertices(page)); + await expect(slider).toHaveValue("-2"); + await expect(page.getByTestId("suggest-detail-label")).toContainText("0.25 px"); + expect(await drawnVertices(page)).toBeGreaterThan(coarse); // And the canvas has its keyboard back the moment the drag ended — without // this the brackets, Esc and Enter are all dead and nothing says why. - await page.keyboard.press("]"); - await expect(slider).toHaveValue("1"); + await page.keyboard.press("["); + await expect(slider).toHaveValue("-1"); await page.keyboard.press("Escape"); await expect(page.getByTestId("suggest-adjustments")).toHaveCount(0); await expect(page.getByTestId("suggestion-shape")).toBeVisible(); From 37548975ffc060896205575bccff6bed750d4519 Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Tue, 25 Aug 2026 07:30:42 -0700 Subject: [PATCH 7/7] docs: the suggestion's outline follows the mask to a pixel tolerance --- docs/content/inference.md | 35 ++++++++++++++++++++++------------- docs/content/ui.md | 27 +++++++++++++++------------ docs/content/ui/annotator.md | 4 ++-- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/docs/content/inference.md b/docs/content/inference.md index 74fd9455..f56a75be 100644 --- a/docs/content/inference.md +++ b/docs/content/inference.md @@ -595,8 +595,8 @@ POST /inference/suggest "contour": [[404.0, 221.0], …] } ], - "applied": {"detail": "balanced"}, - "parameters": ["detail"] + "applied": {"tolerance": 1.0}, + "parameters": ["tolerance"] } ``` @@ -643,18 +643,19 @@ model. 1. **Which pieces** of the mask become shapes. 2. **Closing the gaps** in them that are narrower than a reach. -3. **Tracing** the boundary of what is left. -4. **Simplifying** that boundary to a vertex count somebody can edit. +3. **Tracing** the boundary of what is left along the pixels' edges, and smoothing it once. +4. **Simplifying** that boundary to within a tolerance you choose. The geometry branch happens after the second step: a polygon class takes steps 3 and 4 on the piece you pointed at, a box class takes one extent over every piece that survived. A box -therefore does not move when `detail` does. +therefore does not move when `tolerance` does. | Setting | What it moves | Applies to | | --- | --- | --- | -| `detail` | `coarse`, `balanced` or `fine` - how much of the outline survives | polygon | +| `tolerance` | a distance in the asset's pixels; every point of the traced outline lies within it of the polygon | polygon | -It is optional, and omitting it gives what this route always gave: `balanced`. +It is optional and defaults to `1.0`. It is refused outside `0.25` to `16`, never clamped: a +clamped value would report a tolerance the server did not apply. **Two settings used to be here and are not** (#557). How wide a gap gets closed and how many pieces become shapes are still decided, at fixed defaults nobody asks for. As controls they @@ -663,10 +664,18 @@ shape - so they read as knobs wired to nothing, and could only be got wrong on t one. Their value is in the default rather than in the choice. They come back as settings if a real need for the choice appears. -**The tolerance is relative, which is what makes one setting work everywhere.** It is a fraction -of the region's own size rather than a pixel count, so it does the same thing to a thing eight -pixels across and a thing eight hundred across, and `balanced` keeps a typical object in the -10-40 vertex range. +**The tolerance is a distance, and that is the whole promise.** Every point of the traced +outline lies within `tolerance` pixels of the polygon you get back, so the number means the +same thing on a thing eight pixels across and a thing eight hundred across, and you know what +you will get before you move it. One pixel follows the mask closely on any object; sixteen +gives a rough shape to nudge into place. In the editor, `[` doubles it and `]` halves it, and +the slider runs on a doubling track between the two ends. + +**The outline is the mask's edge, smoothed.** The trace runs along the boundary between lit +and unlit pixels — so a single pixel is its unit square rather than a point — and one pass of +corner cutting over those unit edges turns the staircase a pixel grid imposes into a smooth +line while moving no real corner by more than half a pixel. That smoothed ring, reduced once at +a quarter pixel, is the `contour` every answer carries. **Specks are dropped first, and a click never becomes a cleanup job.** A mask routinely carries more than one separate piece - a scrap of antialiasing along an edge, a reflection, a patch of @@ -691,7 +700,7 @@ the largest piece alone cuts the object off at the occlusion, and a box per piec thing twice. **`parameters` says which settings apply here**, for the kind of shape your `allowed_geometries` -will produce. A box has no outline, so `detail` has nothing to do to one and the list comes back +will produce. A box has no outline, so the tolerance has nothing to do to one and the list comes back **empty** - which is how a client is told to offer no adjustments at all. A client renders what this lists and works none of it out for itself. @@ -704,7 +713,7 @@ makes an outline ragged. Its reach grows with the piece and stops at a few pixel gap is a feature of the shape rather than an artefact of tracing it. **`contour` is the outline the shape was reduced from**, in the asset's own pixels, and it is -there so a client can re-run `detail` without asking again. It is the same points the server +there so a client can re-run the tolerance without asking again. It is the same points the server reduced, which matters: simplification is not nested, so a client starting from anything else could not be held to the server's answer. A box carries none, because it is an extent rather than something reduced from diff --git a/docs/content/ui.md b/docs/content/ui.md index 0a551c55..019e1111 100644 --- a/docs/content/ui.md +++ b/docs/content/ui.md @@ -551,7 +551,7 @@ The gesture: | left-click | adds a point on the object, and asks again | | alt-click | adds a point that is **not** on the object, and asks again | | `↵` | accepts the proposal as an annotation | -| `[` / `]` | coarser or finer, without opening anything | +| `[` / `]` | coarser or finer - doubles or halves the tolerance, without opening anything | | `Esc` | closes the adjustments; then clears the points; then puts the tool away | Every click sends **all** the points placed so far - the route is stateless - and @@ -581,11 +581,11 @@ class and the model's confidence beside it, and is in neither the document nor t undo history. `Esc` is its undo. Switching class, switching frames or leaving the page discards it, and nothing is written. -**Its vertices are drawn the whole time it is up**, which is what makes the detail +**Its vertices are drawn the whole time it is up**, which is what makes the tolerance setting something you can see rather than a number that changes. A committed shape shows its vertices only while it is selected; a proposal is not selected and shows -them anyway, because choosing how much outline to keep is exactly a question about -where the points are. +them anyway, because choosing how far the polygon may drift from the mask is +exactly a question about where the points are. **The shape can be adjusted before it is accepted**, from a section inside the same card - never a second panel over the picture, which would cover the thing @@ -593,19 +593,22 @@ being adjusted. It is closed until you ask for it, because the default is right most of the time. One setting, and whether it appears is the server's answer rather than the -editor's guess (`docs/content/inference.md`). **Detail** is a three-position slider - -coarse, balanced, fine - with a label beside it naming the step and what it costs, -`Fine · 41 pts`. `[` and `]` move it without opening anything. Either way it costs -no request at all: the answer carried the outline it was reduced from, and the -editor re-simplifies it here, so the shape and its vertices move under a held key. +editor's guess (`docs/content/inference.md`). **Detail** is a slider on a +doubling track over the tolerance, from `0.25` px to `16` px, defaulting to +`1.0`, with a label beside it naming the value and what it costs, +`2.0 px · 23 pts`. `[` doubles it and `]` halves it without opening anything. +Either way it costs no request at all: the answer carried the outline it was +reduced from, and the editor re-simplifies it here, so the shape and its +vertices move under a held key. On a small object a coarse tolerance can leave +nothing to show; step finer and the shape comes back. Pressing the slider never takes focus off the canvas, so `[`, `]`, `Esc` and `↵` keep working while you drag it. Tab still reaches it, for driving it from the keyboard on purpose. -On a class that stores a box the section does not appear at all, because detail -changes an outline and a box has none. The editor does not know that; the answer -says so, by naming no settings. +On a class that stores a box the section does not appear at all, because the +tolerance changes an outline and a box has none. The editor does not know that; +the answer says so, by naming no settings. **Two settings were here and are not.** Closing the gaps in the mask and proposing every separate piece are still done, at fixed defaults. As controls they did diff --git a/docs/content/ui/annotator.md b/docs/content/ui/annotator.md index 90668bad..95a4a19a 100644 --- a/docs/content/ui/annotator.md +++ b/docs/content/ui/annotator.md @@ -446,8 +446,8 @@ badges: `classColor` in `frontend/annotator/src/adapters/react/paint.ts`. `ui-co **The suggestion preview is a third visual state, not a shape marked selected.** Selection carries the panel row, the delete key and the keyboard rules a proposal must not have. Its -vertices are up the whole time it is on screen, undecimated at every detail step, because -where precision was gained or lost *is* what the detail control is about. Its outline is +vertices are up the whole time it is on screen, undecimated at every tolerance change, because +where precision was gained or lost *is* what the tolerance control is about. Its outline is dashed and an accepted annotation's is solid, which is what tells proposed from committed at a glance.