From 140f92803aa0c673e5ee98f5f194af1dc6ec6083 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 11:27:18 +0200 Subject: [PATCH 01/57] feat: use analytic fills in local path fitting --- src/vectrify/formats/svg/plugin.py | 14 +++++++++ src/vectrify/refine/paths.py | 49 ++++++++++++++++++++++++++++++ tests/formats/svg/test_plugin.py | 34 +++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/src/vectrify/formats/svg/plugin.py b/src/vectrify/formats/svg/plugin.py index 7b0cd66e..89f5891a 100644 --- a/src/vectrify/formats/svg/plugin.py +++ b/src/vectrify/formats/svg/plugin.py @@ -29,7 +29,9 @@ PATH_FIT, UnsupportedPathError, fit_available, + fit_opaque_fills_locally, fit_random_group, + fittable_opaque_fills, ) log = logging.getLogger(__name__) @@ -157,6 +159,18 @@ def mutate( if reference_png is None or not fit_available(): return content, PATH_FIT try: + # SAMVG seeds are opaque closed fills, so they use the exact + # analytic CUDA fitter. The older sampled operator remains + # the style-specific path for stroked cubic drawings. + if fittable_opaque_fills(content): + return ( + fit_opaque_fills_locally( + content, + reference_png, + gpu_gate=self.gpu_gate, + ), + PATH_FIT, + ) return ( fit_random_group( content, diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 10b85c73..28e83140 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1947,6 +1947,55 @@ def layer_loss( PATH_FIT = "Mutation: path fit" +def fittable_opaque_fills(svg: str) -> bool: + """Whether *svg* contains a fill the analytic CUDA fitter can optimise.""" + import xml.etree.ElementTree as ET + + try: + root = ET.fromstring(svg) + except ET.ParseError: + return False + for element in root.iter(): + if ( + element.tag.split("}")[-1] != "path" + or _fill_rgb(element.get("fill")) is None + ): + continue + try: + parse_filled_cubics(element.get("d", "")) + except UnsupportedPathError: + continue + if element.get("fill-rule", "nonzero").strip().lower() in { + "evenodd", + "nonzero", + }: + return True + return False + + +def fit_opaque_fills_locally( + svg: str, + reference_png: bytes, + *, + steps: int = 8, + gpu_gate: Any = None, +) -> str: + """Use SAMVG's analytic opaque-fill fitter as one local-search move. + + Unlike the legacy stroke fitter this operates on complete filled shapes, + including compound paths and holes. It deliberately keeps the 64px + optimisation raster used by SAMVG; this is a local move, not its 500-step + seed-fitting phase. + """ + from PIL import Image + + if not fittable_opaque_fills(svg): + raise UnsupportedPathError("no opaque filled cubic paths to fit") + target = Image.open(io.BytesIO(reference_png)).convert("RGB") + with gpu_slot(gpu_gate): + return fit_filled_svg(svg, target, steps=steps, optimisation_long_side=64) + + def _stroke_width(element, ancestors) -> float | None: """Return the inherited stroke width, or ``None`` for an unpainted path.""" for node in (element, *ancestors): diff --git a/tests/formats/svg/test_plugin.py b/tests/formats/svg/test_plugin.py index 65edf02f..9b1eead1 100644 --- a/tests/formats/svg/test_plugin.py +++ b/tests/formats/svg/test_plugin.py @@ -222,6 +222,11 @@ def test_a_usable_reply_is_still_applied(): '' ) +_FILLED = ( + '' + '' +) + def test_the_path_fit_is_only_offered_where_it_is_cheap_enough(): """It costs ~0.5s on a GPU against ~9s on one CPU thread, where an ordinary @@ -258,6 +263,35 @@ def test_a_drawing_with_nothing_fittable_reports_a_blank_draw(): assert origin == PATH_FIT +def test_path_fit_dispatches_opaque_fills_to_the_samvg_renderer(monkeypatch): + """SAMVG-style fill seeds must not fall back to the sampled stroke fit.""" + from vectrify.formats.svg import plugin as plugin_module + from vectrify.refine.paths import PATH_FIT + + plugin = SvgPlugin() + seen = {} + + def fit(svg, reference_png, *, gpu_gate): + seen["svg"] = svg + seen["reference"] = reference_png + seen["gpu_gate"] = gpu_gate + return svg.replace("#111111", "#ff0000") + + monkeypatch.setattr(plugin_module, "fit_available", lambda: True) + monkeypatch.setattr(plugin_module, "fit_opaque_fills_locally", fit) + monkeypatch.setattr( + plugin_module, "fit_random_group", lambda *_args, **_kwargs: None + ) + reference = plugin.rasterize(_FILLED, 64, 64) + + content, origin = plugin.mutate(_FILLED, operator=PATH_FIT, reference_png=reference) + + assert origin == PATH_FIT + assert "#ff0000" in content + assert seen["svg"] == _FILLED + assert seen["reference"] == reference + + def test_a_width_on_the_path_is_found_as_readily_as_one_on_the_group(): """The width can be declared on the element, a group above it, or the root. One model wrote it on every path and none on their groups; a lookup that From 53fce3f5d6878561fff43adbb612a50d3a32e79c Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 11:35:15 +0200 Subject: [PATCH 02/57] feat: emit strokes for thin SAMVG masks --- src/vectrify/refine/samvg.py | 115 ++++++++++++++++++++++++++++++----- tests/refine/test_samvg.py | 21 +++++++ 2 files changed, 120 insertions(+), 16 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 115ed43b..d5d98fdb 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -9,6 +9,7 @@ from __future__ import annotations import io +import itertools import logging import math import os @@ -548,6 +549,93 @@ def mask_path( return " ".join(parts) or None +def mask_stroke( + mask: np.ndarray, *, segments: int = 8, overlap_pixels: int = 0 +) -> tuple[str, float] | None: + """Return a conservative centreline stroke for one thin mask component. + + SAMVG itself uses closed filled shapes. This optional hybrid extension is + deliberately strict: a component must be long, narrow, and have no holes + before it can be represented by a stroke. Other masks preserve SAMVG's + original filled-path treatment. + """ + if overlap_pixels: + from scipy.ndimage import binary_dilation + + mask = binary_dilation(mask, iterations=overlap_pixels) + ys, xs = np.nonzero(mask) + if len(xs) < 8: + return None + min_x, max_x = int(xs.min()), int(xs.max()) + min_y, max_y = int(ys.min()), int(ys.max()) + width, height = max_x - min_x + 1, max_y - min_y + 1 + major, minor = max(width, height), min(width, height) + if minor == 0 or major < 12 or major / minor < 3: + return None + # A component's area divided by its long span is its average orthogonal + # width. This rejects narrow-looking leaves and regions with broad ends. + estimated_width = len(xs) / major + if estimated_width > min(8.0, major * 0.18): + return None + # A hole is topology that a single centreline cannot preserve. + if len(_loops(mask)) != 1: + return None + + points = np.column_stack((xs, ys)).astype(np.float64) + centre = points.mean(axis=0) + _values, vectors = np.linalg.eigh(np.cov((points - centre).T)) + direction = vectors[:, -1] + projection = (points - centre) @ direction + bin_count = min(64, max(4, segments * 4)) + bins = np.linspace(projection.min(), projection.max(), bin_count + 1) + line = [] + for start, end in itertools.pairwise(bins): + selected = points[(projection >= start) & (projection <= end)] + if len(selected): + line.append(selected.mean(axis=0)) + if len(line) < 2: + return None + trace = np.asarray(line) + if len(trace) == 2: + data = ( + f"M {trace[0, 0]:.2f} {trace[0, 1]:.2f} " + f"L {trace[1, 0]:.2f} {trace[1, 1]:.2f}" + ) + else: + control_a, control_b = _fit_cubic(trace) + data = ( + f"M {trace[0, 0]:.2f} {trace[0, 1]:.2f} C " + f"{control_a[0]:.2f} {control_a[1]:.2f} " + f"{control_b[0]:.2f} {control_b[1]:.2f} " + f"{trace[-1, 0]:.2f} {trace[-1, 1]:.2f}" + ) + return data, max(1.0, float(estimated_width)) + + +def _layer_svg_attributes(layer: MaskLayer, segments: int) -> dict[str, str] | None: + """Choose the fill or stroke primitive appropriate for one SAM mask.""" + colour = f"#{layer.colour[0]:02x}{layer.colour[1]:02x}{layer.colour[2]:02x}" + stroke = mask_stroke( + layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels + ) + if stroke is not None: + data, width = stroke + return { + "d": data, + "fill": "none", + "stroke": colour, + "stroke-width": f"{width:.2f}", + "stroke-linecap": "round", + "stroke-linejoin": "round", + } + data = mask_path( + layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels + ) + if data is None: + return None + return {"d": data, "fill": colour, "fill-rule": "evenodd"} + + def generate_svg( image: Image.Image, masks: list[np.ndarray] | None = None, @@ -580,12 +668,14 @@ def generate_svg( ) paths = [] for layer in layers: - data = mask_path( - layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels - ) - if data: - colour = f"#{layer.colour[0]:02x}{layer.colour[1]:02x}{layer.colour[2]:02x}" - paths.append(f'') + attributes = _layer_svg_attributes(layer, segments) + if attributes: + markup = " ".join( + f'{key}="{value}"' for key, value in attributes.items() + ) + paths.append( + f"" + ) width, height = image.size return ( f' str: """Add newly prompted paths to an already optimised SVG.""" root = ET.fromstring(svg) for layer in layers: - data = mask_path( - layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels - ) - if not data: + attributes = _layer_svg_attributes(layer, segments) + if attributes is None: continue - colour = f"#{layer.colour[0]:02x}{layer.colour[1]:02x}{layer.colour[2]:02x}" ET.SubElement( root, "{http://www.w3.org/2000/svg}path", - { - "d": data, - "fill": colour, - "fill-rule": "evenodd", - }, + attributes, ) return ET.tostring(root, encoding="unicode") diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 2c32ee9c..996aeb2a 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -15,6 +15,7 @@ filter_by_impact, generate_svg, mask_path, + mask_stroke, recolour_visible_layers, residual_prompt_points, ) @@ -148,6 +149,26 @@ def test_generate_svg_creates_editable_layered_paths_from_supplied_masks(): assert paths[0].get("fill") == "#1482dc" +def test_thin_single_contour_mask_is_emitted_as_a_round_stroke(): + image = Image.new("RGB", (12, 32), "white") + pixels = np.asarray(image).copy() + pixels[4:28, 5:8] = (20, 130, 220) + image = Image.fromarray(pixels) + mask = np.zeros((32, 12), dtype=bool) + mask[4:28, 5:8] = True + + stroke = mask_stroke(mask) + svg = generate_svg(image, [mask], min_pixels=1, min_impact=0.00001) + path = ET.fromstring(svg).find("{http://www.w3.org/2000/svg}path") + + assert stroke is not None + assert path is not None + assert path.get("fill") == "none" + assert path.get("stroke") == "#1482dc" + assert path.get("stroke-linecap") == "round" + assert " Z" not in path.get("d", "") + + def test_coverage_prompt_points_selects_the_centre_of_a_large_empty_region(): occupied = np.zeros((32, 32), dtype=bool) occupied[:, :12] = True From 1e121c418441e09883294dc20eb104aa057efb20 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 11:45:04 +0200 Subject: [PATCH 03/57] feat: add Torch OCR to SAMVG seeds --- pyproject.toml | 1 + src/vectrify/cli.py | 3 +- src/vectrify/refine/samvg.py | 121 ++++++++- tests/refine/test_samvg.py | 49 ++++ uv.lock | 494 +++++++++++++++++++++++++++++++++++ 5 files changed, 656 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d0e9d192..9efcee20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ samvg = [ "torch>=2.0.0", "torchvision>=0.28.0", "transformers>=4.40.0", + "easyocr>=1.7.2", ] graphviz = [ "graphviz>=0.21", diff --git a/src/vectrify/cli.py b/src/vectrify/cli.py index 4ece47d4..b61c0fd6 100644 --- a/src/vectrify/cli.py +++ b/src/vectrify/cli.py @@ -201,7 +201,8 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace: action=argparse.BooleanOptionalAction, default=True, help="Add one SAMVG-inspired SVG seed made from automatic SAM masks, " - "impact filtering, and contour tracing. Requires vectrify[vision] and " + "impact filtering, contour tracing, and Torch OCR. Requires " + "vectrify[samvg] and " "is available for SVG output only. Default: on", ) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index d5d98fdb..69abd37a 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -17,6 +17,7 @@ from collections import defaultdict from dataclasses import dataclass from typing import cast +from xml.sax.saxutils import escape import numpy as np from PIL import Image @@ -44,6 +45,103 @@ class MaskLayer: overlap_pixels: int = 0 +@dataclass(frozen=True) +class TextLayer: + """A high-confidence OCR word represented as editable SVG text.""" + + text: str + x: float + y: float + width: float + height: float + colour: tuple[int, int, int] + angle: float = 0.0 + + +def _text_colour(pixels: np.ndarray) -> tuple[int, int, int]: + """Estimate ink colour by contrasting a word crop with its border.""" + height, width, _channels = pixels.shape + if height < 3 or width < 3: + colour = pixels.reshape(-1, 3).mean(axis=0) + else: + border = np.concatenate( + (pixels[0], pixels[-1], pixels[1:-1, 0], pixels[1:-1, -1]) + ) + background = border.mean(axis=0) + distance = np.linalg.norm(pixels.astype(np.float32) - background, axis=2) + ink = pixels[distance >= np.percentile(distance, 80)] + colour = ink.mean(axis=0) if len(ink) else background + return cast(tuple[int, int, int], tuple(int(value) for value in np.rint(colour))) + + +def detect_text(image: Image.Image, *, confidence: float = 0.7) -> list[TextLayer]: + """Read editable words with EasyOCR's Torch detector and recogniser. + + The detector is deliberately conservative. The SVG font is necessarily an + approximation of the source font, so uncertain single characters remain + with the normal SAMVG filled-path pipeline. + """ + try: + import easyocr + import torch + except ImportError as exc: # pragma: no cover - installation-specific + raise ImportError( + "SAMVG OCR requires the samvg extra. Install 'vectrify[samvg]'." + ) from exc + reader = easyocr.Reader(["en"], gpu=torch.cuda.is_available(), verbose=False) + source = np.asarray(image.convert("RGB")) + detected: list[TextLayer] = [] + for box, text, score in reader.readtext(source, detail=1, paragraph=False): + if float(score) < confidence or len(text.strip()) < 2: + continue + corners = np.asarray(box, dtype=np.float32) + if corners.shape != (4, 2): + continue + x, y = corners.min(axis=0) + right, bottom = corners.max(axis=0) + width, height = float(right - x), float(bottom - y) + if width < 4 or height < 4: + continue + direction = corners[1] - corners[0] + angle = math.degrees(math.atan2(float(direction[1]), float(direction[0]))) + crop = source[ + max(0, math.floor(y)) : math.ceil(bottom), + max(0, math.floor(x)) : math.ceil(right), + ] + if not crop.size: + continue + detected.append( + TextLayer( + text=text.strip(), + x=float(x), + y=float(y), + width=width, + height=height, + colour=_text_colour(crop), + angle=angle, + ) + ) + log.info("SAMVG OCR: retained %d editable text layer(s).", len(detected)) + return detected + + +def _text_svg_attributes(layer: TextLayer) -> dict[str, str]: + """Map OCR geometry to a portable editable SVG text element.""" + colour = f"#{layer.colour[0]:02x}{layer.colour[1]:02x}{layer.colour[2]:02x}" + attributes = { + "x": f"{layer.x:.2f}", + "y": f"{layer.y + layer.height * 0.8:.2f}", + "font-family": "sans-serif", + "font-size": f"{layer.height:.2f}", + "fill": colour, + } + if abs(layer.angle) > 1: + attributes["transform"] = ( + f"rotate({layer.angle:.2f} {layer.x:.2f} {layer.y:.2f})" + ) + return attributes + + def _is_crop_edge_mask( mask: np.ndarray, crop_box: tuple[int, int, int, int], @@ -73,7 +171,7 @@ def automatic_masks(image: Image.Image) -> list[np.ndarray]: from transformers import pipeline except ImportError as exc: # pragma: no cover - installation-specific raise ImportError( - "SAMVG requires the vision extra. Install 'vectrify[vision]'." + "SAMVG requires the samvg extra. Install 'vectrify[samvg]'." ) from exc image = image.convert("RGB") generator = pipeline("mask-generation", model=SAMVG_MODEL, device=0) @@ -628,9 +726,7 @@ def _layer_svg_attributes(layer: MaskLayer, segments: int) -> dict[str, str] | N "stroke-linecap": "round", "stroke-linejoin": "round", } - data = mask_path( - layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels - ) + data = mask_path(layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels) if data is None: return None return {"d": data, "fill": colour, "fill-rule": "evenodd"} @@ -645,6 +741,7 @@ def generate_svg( max_layers: int = 512, segments: int = 16, fill_holes: bool = True, + ocr: bool = True, ) -> str: """Generate SAMVG's traced, pre-optimisation SVG from a target image.""" image = image.convert("RGB") @@ -670,16 +767,18 @@ def generate_svg( for layer in layers: attributes = _layer_svg_attributes(layer, segments) if attributes: - markup = " ".join( - f'{key}="{value}"' for key, value in attributes.items() - ) - paths.append( - f"" - ) + markup = " ".join(f'{key}="{value}"' for key, value in attributes.items()) + paths.append(f"") + text_layers = detect_text(image) if ocr and masks is None else [] + text = [] + for layer in text_layers: + attributes = _text_svg_attributes(layer) + markup = " ".join(f'{key}="{value}"' for key, value in attributes.items()) + text.append(f"{escape(layer.text)}") width, height = image.size return ( f'' + "".join(paths) + "" + f'viewBox="0 0 {width} {height}">' + "".join(paths) + "".join(text) + "" ) diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 996aeb2a..e6499add 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -5,13 +5,17 @@ import numpy as np from PIL import Image +import vectrify.refine.samvg as samvg from vectrify.refine.samvg import ( MaskLayer, + TextLayer, _components, _fit_cubic, _is_crop_edge_mask, + _text_svg_attributes, automatic_masks, coverage_prompt_points, + detect_text, filter_by_impact, generate_svg, mask_path, @@ -21,6 +25,51 @@ ) +def test_detect_text_retains_high_confidence_editable_words(monkeypatch): + class Reader: + def __init__(self, languages, *, gpu, verbose): + assert languages == ["en"] + assert gpu is True + assert verbose is False + + def readtext(self, source, **kwargs): + assert source.shape == (16, 32, 3) + assert kwargs == {"detail": 1, "paragraph": False} + return [ + ([[2, 3], [20, 3], [20, 11], [2, 11]], "Cats & dogs", 0.94), + ([[2, 12], [4, 12], [4, 14], [2, 14]], "I", 0.99), + ([[2, 3], [20, 3], [20, 11], [2, 11]], "blur", 0.2), + ] + + monkeypatch.setitem(sys.modules, "easyocr", SimpleNamespace(Reader=Reader)) + monkeypatch.setitem( + sys.modules, + "torch", + SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)), + ) + + layers = detect_text(Image.new("RGB", (32, 16), "white")) + + assert layers == [TextLayer("Cats & dogs", 2.0, 3.0, 18.0, 8.0, (255, 255, 255))] + assert _text_svg_attributes(layers[0])["font-family"] == "sans-serif" + + +def test_generate_svg_writes_detected_words_as_editable_text(monkeypatch): + monkeypatch.setattr(samvg, "retrieve_layers", lambda *_args, **_kwargs: []) + monkeypatch.setattr( + samvg, + "detect_text", + lambda _image: [TextLayer("Cats & dogs", 2, 3, 18, 8, (20, 30, 40))], + ) + + root = ET.fromstring(generate_svg(Image.new("RGB", (32, 16)))) + text = root.find("{http://www.w3.org/2000/svg}text") + + assert text is not None + assert text.text == "Cats & dogs" + assert text.get("font-size") == "8.00" + + def test_automatic_masks_uses_source_sized_first_layer_crops(monkeypatch): calls = [] diff --git a/uv.lock b/uv.lock index 6e520606..90f786e2 100644 --- a/uv.lock +++ b/uv.lock @@ -665,6 +665,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "easyocr" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ninja" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pyclipper" }, + { name = "python-bidi" }, + { name = "pyyaml" }, + { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "shapely" }, + { name = "torch" }, + { name = "torchvision" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/84/4a2cab0e6adde6a85e7ba543862e5fc0250c51f3ac721a078a55cdcff250/easyocr-1.7.2-py3-none-any.whl", hash = "sha256:5be12f9b0e595d443c9c3d10b0542074b50f0ec2d98b141a109cd961fd1c177c", size = 2870178, upload-time = "2024-09-24T11:34:43.554Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -899,6 +926,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "imageio" +version = "2.37.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3", size = 390173, upload-time = "2026-07-20T05:26:11.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6", size = 318000, upload-time = "2026-07-20T05:26:09.874Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1152,6 +1194,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1460,6 +1514,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] +[[package]] +name = "ninja" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/73/79a0b22fc731989c708068427579e840a6cf4e937fe7ae5c5d0b7356ac22/ninja-1.13.0.tar.gz", hash = "sha256:4a40ce995ded54d9dc24f8ea37ff3bf62ad192b547f6c7126e7e25045e76f978", size = 242558, upload-time = "2025-08-11T15:10:19.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/74/d02409ed2aa865e051b7edda22ad416a39d81a84980f544f8de717cab133/ninja-1.13.0-py3-none-macosx_10_9_universal2.whl", hash = "sha256:fa2a8bfc62e31b08f83127d1613d10821775a0eb334197154c4d6067b7068ff1", size = 310125, upload-time = "2025-08-11T15:09:50.971Z" }, + { url = "https://files.pythonhosted.org/packages/8e/de/6e1cd6b84b412ac1ef327b76f0641aeb5dcc01e9d3f9eee0286d0c34fd93/ninja-1.13.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630", size = 177467, upload-time = "2025-08-11T15:09:52.767Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/49320fb6e58ae3c079381e333575fdbcf1cca3506ee160a2dcce775046fa/ninja-1.13.0-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:be7f478ff9f96a128b599a964fc60a6a87b9fa332ee1bd44fa243ac88d50291c", size = 187834, upload-time = "2025-08-11T15:09:54.115Z" }, + { url = "https://files.pythonhosted.org/packages/56/c7/ba22748fb59f7f896b609cd3e568d28a0a367a6d953c24c461fe04fc4433/ninja-1.13.0-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:60056592cf495e9a6a4bea3cd178903056ecb0943e4de45a2ea825edb6dc8d3e", size = 202736, upload-time = "2025-08-11T15:09:55.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/22/d1de07632b78ac8e6b785f41fa9aad7a978ec8c0a1bf15772def36d77aac/ninja-1.13.0-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1c97223cdda0417f414bf864cfb73b72d8777e57ebb279c5f6de368de0062988", size = 179034, upload-time = "2025-08-11T15:09:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa", size = 180716, upload-time = "2025-08-11T15:09:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/938b562f9057aaa4d6bfbeaa05e81899a47aebb3ba6751e36c027a7f5ff7/ninja-1.13.0-py3-none-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4be9c1b082d244b1ad7ef41eb8ab088aae8c109a9f3f0b3e56a252d3e00f42c1", size = 146843, upload-time = "2025-08-11T15:10:00.046Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fb/d06a3838de4f8ab866e44ee52a797b5491df823901c54943b2adb0389fbb/ninja-1.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6739d3352073341ad284246f81339a384eec091d9851a886dfa5b00a6d48b3e2", size = 154402, upload-time = "2025-08-11T15:10:01.657Z" }, + { url = "https://files.pythonhosted.org/packages/31/bf/0d7808af695ceddc763cf251b84a9892cd7f51622dc8b4c89d5012779f06/ninja-1.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:11be2d22027bde06f14c343f01d31446747dbb51e72d00decca2eb99be911e2f", size = 552388, upload-time = "2025-08-11T15:10:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c99d0c2c809f992752453cce312848abb3b1607e56d4cd1b6cded317351a/ninja-1.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aa45b4037b313c2f698bc13306239b8b93b4680eb47e287773156ac9e9304714", size = 472501, upload-time = "2025-08-11T15:10:04.735Z" }, + { url = "https://files.pythonhosted.org/packages/9f/43/c217b1153f0e499652f5e0766da8523ce3480f0a951039c7af115e224d55/ninja-1.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f8e1e8a1a30835eeb51db05cf5a67151ad37542f5a4af2a438e9490915e5b72", size = 638280, upload-time = "2025-08-11T15:10:06.512Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9151bba2c8d0ae2b6260f71696330590de5850e5574b7b5694dce6023e20/ninja-1.13.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:3d7d7779d12cb20c6d054c61b702139fd23a7a964ec8f2c823f1ab1b084150db", size = 642420, upload-time = "2025-08-11T15:10:08.35Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/95752eb635bb8ad27d101d71bef15bc63049de23f299e312878fc21cb2da/ninja-1.13.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:d741a5e6754e0bda767e3274a0f0deeef4807f1fec6c0d7921a0244018926ae5", size = 585106, upload-time = "2025-08-11T15:10:09.818Z" }, + { url = "https://files.pythonhosted.org/packages/c1/31/aa56a1a286703800c0cbe39fb4e82811c277772dc8cd084f442dd8e2938a/ninja-1.13.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:e8bad11f8a00b64137e9b315b137d8bb6cbf3086fbdc43bf1f90fd33324d2e96", size = 707138, upload-time = "2025-08-11T15:10:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/34/6f/5f5a54a1041af945130abdb2b8529cbef0cdcbbf9bcf3f4195378319d29a/ninja-1.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b4f2a072db3c0f944c32793e91532d8948d20d9ab83da9c0c7c15b5768072200", size = 581758, upload-time = "2025-08-11T15:10:13.295Z" }, + { url = "https://files.pythonhosted.org/packages/95/97/51359c77527d45943fe7a94d00a3843b81162e6c4244b3579fe8fc54cb9c/ninja-1.13.0-py3-none-win32.whl", hash = "sha256:8cfbb80b4a53456ae8a39f90ae3d7a2129f45ea164f43fadfa15dc38c4aef1c9", size = 267201, upload-time = "2025-08-11T15:10:15.158Z" }, + { url = "https://files.pythonhosted.org/packages/29/45/c0adfbfb0b5895aa18cec400c535b4f7ff3e52536e0403602fc1a23f7de9/ninja-1.13.0-py3-none-win_amd64.whl", hash = "sha256:fb8ee8719f8af47fed145cced4a85f0755dd55d45b2bddaf7431fa89803c5f3e", size = 309975, upload-time = "2025-08-11T15:10:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/a7b983643d1253bb223234b5b226e69de6cda02b76cdca7770f684b795f5/ninja-1.13.0-py3-none-win_arm64.whl", hash = "sha256:3c0b40b1f0bba764644385319028650087b4c1b18cdfa6f45cb39a3669b81aa9", size = 290806, upload-time = "2025-08-11T15:10:18.018Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -1850,6 +1930,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" }, ] +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1998,6 +2099,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] +[[package]] +name = "pyclipper" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/9f/a10173d32ecc2ce19a04d018163f3ca22a04c0c6ad03b464dcd32f9152a8/pyclipper-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bafad70d2679c187120e8c44e1f9a8b06150bad8c0aecf612ad7dfbfa9510f73", size = 264510, upload-time = "2025-12-01T13:14:46.551Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/5490ddc4a1f7ceeaa0258f4266397e720c02db515b2ca5bc69b85676f697/pyclipper-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b74a9dd44b22a7fd35d65fb1ceeba57f3817f34a97a28c3255556362e491447", size = 139498, upload-time = "2025-12-01T13:14:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/3b/0a/bea9102d1d75634b1a5702b0e92982451a1eafca73c4845d3dbe27eba13d/pyclipper-1.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a4d2736fb3c42e8eb1d38bf27a720d1015526c11e476bded55138a977c17d9d", size = 970974, upload-time = "2025-12-01T13:14:49.799Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1b/097f8776d5b3a10eb7b443b632221f4ed825d892e79e05682f4b10a1a59c/pyclipper-1.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3b3630051b53ad2564cb079e088b112dd576e3d91038338ad1cc7915e0f14dc", size = 943315, upload-time = "2025-12-01T13:14:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/17d6a3f1abf0f368d58f2309e80ee3761afb1fd1342f7780ab32ba4f0b1d/pyclipper-1.4.0-cp310-cp310-win32.whl", hash = "sha256:8d42b07a2f6cfe2d9b87daf345443583f00a14e856927782fde52f3a255e305a", size = 95286, upload-time = "2025-12-01T13:14:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/ca/b30138427ed122ec9b47980b943164974a2ec606fa3f71597033b9a9f9a6/pyclipper-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:6a97b961f182b92d899ca88c1bb3632faea2e00ce18d07c5f789666ebb021ca4", size = 104227, upload-time = "2025-12-01T13:14:54.013Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/64cf7794319b088c288706087141e53ac259c7959728303276d18adc665d/pyclipper-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:adcb7ca33c5bdc33cd775e8b3eadad54873c802a6d909067a57348bcb96e7a2d", size = 264281, upload-time = "2025-12-01T13:14:55.47Z" }, + { url = "https://files.pythonhosted.org/packages/34/cd/44ec0da0306fa4231e76f1c2cb1fa394d7bde8db490a2b24d55b39865f69/pyclipper-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fd24849d2b94ec749ceac7c34c9f01010d23b6e9d9216cf2238b8481160e703d", size = 139426, upload-time = "2025-12-01T13:14:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/ad/88/d8f6c6763ea622fe35e19c75d8b39ed6c55191ddc82d65e06bc46b26cb8e/pyclipper-1.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b6c8d75ba20c6433c9ea8f1a0feb7e4d3ac06a09ad1fd6d571afc1ddf89b869", size = 989649, upload-time = "2025-12-01T13:14:58.28Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e9/ea7d68c8c4af3842d6515bedcf06418610ad75f111e64c92c1d4785a1513/pyclipper-1.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e29d7443d7cc0e83ee9daf43927730386629786d00c63b04fe3b53ac01462c", size = 962842, upload-time = "2025-12-01T13:15:00.044Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/0b4a272d8726e51ab05e2b933d8cc47f29757fb8212e38b619e170e6015c/pyclipper-1.4.0-cp311-cp311-win32.whl", hash = "sha256:a8d2b5fb75ebe57e21ce61e79a9131edec2622ff23cc665e4d1d1f201bc1a801", size = 95098, upload-time = "2025-12-01T13:15:01.359Z" }, + { url = "https://files.pythonhosted.org/packages/3a/76/4901de2919198bb2bd3d989f86d4a1dff363962425bb2d63e24e6c990042/pyclipper-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:e9b973467d9c5fa9bc30bb6ac95f9f4d7c3d9fc25f6cf2d1cc972088e5955c01", size = 104362, upload-time = "2025-12-01T13:15:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/cbce7d47de1e6458f66a4d999b091640134deb8f2c7351eab993b70d2e10/pyclipper-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e", size = 264342, upload-time = "2025-12-01T13:15:12.697Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cc/742b9d69d96c58ac156947e1b56d0f81cbacbccf869e2ac7229f2f86dc4e/pyclipper-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a", size = 139839, upload-time = "2025-12-01T13:15:13.911Z" }, + { url = "https://files.pythonhosted.org/packages/db/48/dd301d62c1529efdd721b47b9e5fb52120fcdac5f4d3405cfc0d2f391414/pyclipper-1.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f", size = 972142, upload-time = "2025-12-01T13:15:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/07/bf/d493fd1b33bb090fa64e28c1009374d5d72fa705f9331cd56517c35e381e/pyclipper-1.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1", size = 952789, upload-time = "2025-12-01T13:15:17.042Z" }, + { url = "https://files.pythonhosted.org/packages/cf/88/b95ea8ea21ddca34aa14b123226a81526dd2faaa993f9aabd3ed21231604/pyclipper-1.4.0-cp313-cp313-win32.whl", hash = "sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c", size = 94817, upload-time = "2025-12-01T13:15:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/ba/42/0a1920d276a0e1ca21dc0d13ee9e3ba10a9a8aa3abac76cd5e5a9f503306/pyclipper-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832", size = 104007, upload-time = "2025-12-01T13:15:19.763Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" }, + { url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/81050abdc9e5b90ffc2c765738c5e40e9abd8e44864aaa737b600f16c562/pyclipper-1.4.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98b2a40f98e1fc1b29e8a6094072e7e0c7dfe901e573bf6cfc6eb7ce84a7ae87", size = 126495, upload-time = "2025-12-01T13:15:33.743Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2206,6 +2350,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-bidi" +version = "0.6.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/f168f2c3151aa05b9f9c9b2f7767bc8e06a133ea822c231ab497d4f36833/python_bidi-0.6.11.tar.gz", hash = "sha256:034090c597af250d699299d7e7f1e83eb016f9e47b3b707bd89ab2bdec77bce0", size = 57647, upload-time = "2026-06-30T14:23:42.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/54/439befffac4b2d14c965928175cdf45586680824adf01fc19a6bcc7b0342/python_bidi-0.6.11-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a9aa2890661b238730e680ccd6eb06f4da625b2dbc1730052dae6f2d88957192", size = 269604, upload-time = "2026-06-30T14:22:41.007Z" }, + { url = "https://files.pythonhosted.org/packages/77/fc/723f92efcbfb326b9091a6acc1385bbec4e0a3f5850fc360ee048ad9c9ab/python_bidi-0.6.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41c7d1914c1f33954aa2ab0e2c309be5d1d4afc75ce524762eaab4dd825c5ab8", size = 269850, upload-time = "2026-06-30T14:22:30.786Z" }, + { url = "https://files.pythonhosted.org/packages/36/9a/8dc0e4613acdcca5f14efe327f6fc0f5f0908288a3c6fc7d1d368ee96c52/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57aa56b1eca5f63ebdd25fe8fad02eab7797fc45ad1efc5eed2a830e2bd2038d", size = 295625, upload-time = "2026-06-30T14:21:27.876Z" }, + { url = "https://files.pythonhosted.org/packages/a8/66/812b9c6ed40021c5e1a04d5a65c347c339f89057642e48bc2b8bead0c83a/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5695d87969fed5b3799b8a98848cb04fe2873fded46efe9f3f2f341efd1b829", size = 300843, upload-time = "2026-06-30T14:21:39.289Z" }, + { url = "https://files.pythonhosted.org/packages/27/78/f6aedee9fdafd59faba64bc053efcee2ac9ad7d3971311095bb36d542c00/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da51a3a2940478219f19249fcf7cd45e8ddc197982be22efa43c4763e9d2eb57", size = 418888, upload-time = "2026-06-30T14:21:49.447Z" }, + { url = "https://files.pythonhosted.org/packages/47/32/cdb85b5aca0c5055a351aee768816ac43bbde6a0f9d2b71866ed41e93211/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56807e0dde88d5ab96880c9d668bda7bca1df83cd30d20cbff5d6b6e6c1e9276", size = 321304, upload-time = "2026-06-30T14:21:59.874Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/c4c4c790d697464c77f87e7741c2bc438d761d7783950415d6e238f7a7c0/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1115f3f02eb836b39e0c986a6b9e92c4377a1e5a680409650b02ec6b1a795e6", size = 299426, upload-time = "2026-06-30T14:22:20.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/5e/84ea7bb7dfdc232f0bc2a58f957320b289a01ad6010098c245d4abad092a/python_bidi-0.6.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ad5f712a32be30d28eb96e119e85818747a43cea253de6c3160b464b62a5619", size = 316772, upload-time = "2026-06-30T14:22:10.044Z" }, + { url = "https://files.pythonhosted.org/packages/18/8e/295372d9d17160d74babae10014a67527917bbb7cde1905d01174d6efc1f/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8013c1e6267a929be98d10c3a617171709c8226bd33c6180ec65e560c35b6df8", size = 471770, upload-time = "2026-06-30T14:22:52.148Z" }, + { url = "https://files.pythonhosted.org/packages/3f/78/28623ac1401586cc469f67c1fa7254ba1e1507c1a34a6c75b90dd5e8fc3a/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ff675d036823bff05a2e0f8cdb13f5414274ff517d13b0d57c15527015eb6acb", size = 576384, upload-time = "2026-06-30T14:23:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d4/36e34e27181df73ef9ed7091aa710ca052df2e76bc20cdfca87f3be67c5c/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fa2848c3116d619870d114471fcd4a9f1aa43587a002111d7af1e6582f1b57e9", size = 537321, upload-time = "2026-06-30T14:23:16.754Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/6db9deaa5f8213506be2ea79279fdf9397e4bda7be42c6f8d638ef334a86/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:493655043ebea6fec0edb76053bdad46f1f7352a759ca4deb733f13b3c09839c", size = 503988, upload-time = "2026-06-30T14:23:29.455Z" }, + { url = "https://files.pythonhosted.org/packages/6d/65/9b1bc1b056b31bdd658906c95ea40e188ae74c61b1ce89e978fe587a7bb1/python_bidi-0.6.11-cp310-cp310-win32.whl", hash = "sha256:62a6b700f3d7a2c4d52a5dccca765711a2414e734360b00365e155698a26e461", size = 158574, upload-time = "2026-06-30T14:23:52.599Z" }, + { url = "https://files.pythonhosted.org/packages/98/f2/7507dc1b0e513e46de273875099bdc83a18f50f9da176d02dbc25612bc22/python_bidi-0.6.11-cp310-cp310-win_amd64.whl", hash = "sha256:fccd1808bb427d6a6d34168461bef551faa93ce3dda489fcef9073bcd9b34a5e", size = 163124, upload-time = "2026-06-30T14:23:43.648Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/7f942cdb3cc948a369bfe2530343f2d650aa17bb04b0b959834919f699a4/python_bidi-0.6.11-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:56f27c1edfd15c12c9c348378ccd79166930d720cf316b1181a0a0ade2146253", size = 269449, upload-time = "2026-06-30T14:22:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6e/af3e17cb48b87176c209ac4271c8a9aaad8c33f5535739b58336222e69af/python_bidi-0.6.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a52f7ad9ef9091e81869e5d255e796755ccf542ade14dda17647cb7d7ffe1b9c", size = 269840, upload-time = "2026-06-30T14:22:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/91/62/f7303a11e8286b2219088bb863974398a1a9f117444e78bbfcefaef7bc14/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4ebc24ac38e50676f65daf7ba6c568789660cb60d6dcf2606d4310dba826721", size = 295322, upload-time = "2026-06-30T14:21:29.37Z" }, + { url = "https://files.pythonhosted.org/packages/29/38/930e63c374133760f69159da36a7aba98368ade44ae708215addc4079d91/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:969ee7db3e169fcc0b2d2d094826e03cc5798dfd6b3571a340ea883672396cb1", size = 300777, upload-time = "2026-06-30T14:21:40.302Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/f92408a2882ed7c94c3df9038df48400a5adc345e963d488bf716ad25358/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d496f9fc21b7457e12395e54088ab99776966c663b4cd4a74770c7a6418ab59", size = 419532, upload-time = "2026-06-30T14:21:50.531Z" }, + { url = "https://files.pythonhosted.org/packages/24/0c/5fb11159f50e9a898862fa40fe98b8994eccd224189bb1c805ecfae66977/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad5d9b8e8a6c330208eba413db506de58f21dbf88a1f1d5d75ef5f9e0e714adf", size = 320985, upload-time = "2026-06-30T14:22:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/16/d3/6bd8b189219ec128263b7277b1cdaed0cf014199c61e05793be2d9cb0456/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acccb6d90e694684db2d314db2e2b0d3b8949bf1cfaec6d9808a8889f548add7", size = 299186, upload-time = "2026-06-30T14:22:21.702Z" }, + { url = "https://files.pythonhosted.org/packages/36/9e/b7da9c128f5ed867a62cb8112602bc7a6b6af783bbac18ca303a8a09b868/python_bidi-0.6.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b5ee069001bf7f4fff109598a9396caa96bb35e1b906b2c6d1bab9f9b2c4bd", size = 316856, upload-time = "2026-06-30T14:22:11.328Z" }, + { url = "https://files.pythonhosted.org/packages/72/a9/f5e4c286ae22fff569eb7aeeec7a5342e26e0ed1ac8202c48cf273932b28/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:619ee3fe03daec8d3ce12239f0c22455676a063b2bcde361caecd788fae5b8d5", size = 471488, upload-time = "2026-06-30T14:22:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/47d84a333ee00db0cff74fde2aa39e01953abb360d14ffed70ad9195af26/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:af7711cc6eeeadcb1aae877e0736a68e111c73a29562da6106c5e2fbb4dd83b2", size = 576451, upload-time = "2026-06-30T14:23:05.961Z" }, + { url = "https://files.pythonhosted.org/packages/50/b1/1c158a64d745e4916fbc39c6ad700c5b2cf3b4456f710a62f625fba0694f/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b2c759e13ebb81edaac3041697328bb1ca8433b55281723879f6b54e74881240", size = 537476, upload-time = "2026-06-30T14:23:18.118Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7c/eeaad2247b29f736cda2632d14c5959940a4b4e8e098559cd2a4065356eb/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b433840a924c8788f0abbda15d22c71dc636e2078d7d3ba39369cebe4bef74b8", size = 503885, upload-time = "2026-06-30T14:23:31.134Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d0/ec71ea3e29cc745580ac5477bbf3c9235782ab0c7bc08ac065e4c7cb12ec/python_bidi-0.6.11-cp311-cp311-win32.whl", hash = "sha256:8b6b7fce8f47578be9aebf5a0b6b2d6c157b4e97af7586ecf13bfca5d128deea", size = 158704, upload-time = "2026-06-30T14:23:53.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/98/0458bf0adcf09f1766e069f6d1265d83b8354146e689927f0d14451bf4e2/python_bidi-0.6.11-cp311-cp311-win_amd64.whl", hash = "sha256:555cdf9303c40bae1ab512ca427f1f0316a574bc0a48db22eec76ec0fd1213cf", size = 163236, upload-time = "2026-06-30T14:23:44.741Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ad/e2ff0e5077de577211d7d4fd6a436a97d903d6c65e8deb4c958de901b0eb/python_bidi-0.6.11-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:83ee87feb5eafc0442e1db0014dad20d52a2a7a140b6cddc8f7bc65918f0a7b4", size = 267279, upload-time = "2026-06-30T14:22:43.34Z" }, + { url = "https://files.pythonhosted.org/packages/08/19/776b39e47e0bde27000fc2e68c2dd0bad023d4c470dcd5ec9c98779a62c8/python_bidi-0.6.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d6970b09f5a3102c0aa192f5258c585742ab4ebd94f637a635ad3448ccba567", size = 265032, upload-time = "2026-06-30T14:22:32.901Z" }, + { url = "https://files.pythonhosted.org/packages/cb/fc/87f3b820bbee3620bdd89047ee617db49700719a632d149e1c8a8c6ec59b/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:495a76c881d78ab87b57c5270679c9bc3c1de36d8c6596d5e3a5a1b5f9c57471", size = 292228, upload-time = "2026-06-30T14:21:30.411Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/e48c592fcd01409bd09eb1a181c31702f7c048d06bca004b8840d101f69a/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9af2b5c26a3eb960699dff040535a86dc2c0f708087b2d63bcfd6452fe9d0664", size = 297708, upload-time = "2026-06-30T14:21:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/77/68/9da530ac64b961f5dcdcaad03d90b35becb43dd7d244241809da11acca34/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f99162d6c6c9522c46eb213f1bc932829c2602131676c92f081cb865b8ef6784", size = 415289, upload-time = "2026-06-30T14:21:51.919Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/ce62bfec64769c28d537ef0481a36dd9ee42795b2975b41846f1aa82f7cc/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:caee7ee3662eab1411b44fef8571b87273cb5235061d7463ebd10e412ac07986", size = 318361, upload-time = "2026-06-30T14:22:02.282Z" }, + { url = "https://files.pythonhosted.org/packages/09/fb/57a496606a4faf051a3e851cc05a5bab2a1cd37b14fc738a707a5a51bba8/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3611d13b53d4c899c4f2a7cd8eb897064e8b5546c3c5d1037dd6209c82858a27", size = 296198, upload-time = "2026-06-30T14:22:22.914Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/31fbe166932d34860271ec5e0e0ecf0bc4166bddb889a260962a448d8617/python_bidi-0.6.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ca92a4e460f7e25e434a6d7982d94a4765ca242f527995da70abb5a32003b8a", size = 313188, upload-time = "2026-06-30T14:22:12.589Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ea/0c2ae4a316ea919698ede4201da1c07dc1c2f9a86bd3bce3f9b94b0ce7e6/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:539d99efe02f4981171ed57bbc085094ef780405ca14663550a56c6c3e265c34", size = 468437, upload-time = "2026-06-30T14:22:54.946Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/4795fb7f3ddca33a981158437ff6bd4c532d1011b9d887f47cff45d035e8/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9435acc52438c3c8f5142b9a17a622927618e80a32eed707343bc375cb51cebe", size = 573566, upload-time = "2026-06-30T14:23:07.133Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/8e707fb95cec1afed3fbffbf5eee93f2a73cad6f1445e17e32c6256cf397/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:239a00b2adb5f897d11d7b7a491d759fb9883e71a71cfd90c4147a733b4df4d3", size = 533591, upload-time = "2026-06-30T14:23:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4c/5e8d01ed3d2f8ca1116e1e21085a5ca97450b4035138988e5a82ea2db916/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:51915502898c45e9cb36636e974aca068fc5cdb9f06b794f49abea5b12f02016", size = 500360, upload-time = "2026-06-30T14:23:32.445Z" }, + { url = "https://files.pythonhosted.org/packages/b0/77/86bf9c4a95f363451e7c322ef76afbe56034da23a2b5532bd774751022ff/python_bidi-0.6.11-cp312-cp312-win32.whl", hash = "sha256:6c92d1cad16f9ec2f2a3ae439a0bc3a8e4189ec227987bed03d5b4056d5eb9c5", size = 157049, upload-time = "2026-06-30T14:23:54.895Z" }, + { url = "https://files.pythonhosted.org/packages/04/e3/8912d05e04575a60a0481cc222805331b74154940528a6f419fc5bbba744/python_bidi-0.6.11-cp312-cp312-win_amd64.whl", hash = "sha256:0608bddcc1c53dfa5293499de13ca9935b31aa46d1c722c404a88c703d1a4e47", size = 161243, upload-time = "2026-06-30T14:23:45.819Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/38f195e9d9a144747a2ca5ed6ec922df50534e6bacb2188a27154c9f9400/python_bidi-0.6.11-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b41cc6bc9ad78a12da5f987da15e931c771f18ceca58f2fe8ed50f253490a97", size = 266692, upload-time = "2026-06-30T14:22:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a0/4a29e0bfa45038edeeac9397c0c91aee674efbbaa962f0c32c17aaf1a2c4/python_bidi-0.6.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4d00757ef7bbbf14d8628f9bdb6b0e168d5e7b03fec20da3226624f11bffce89", size = 264510, upload-time = "2026-06-30T14:22:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/6c/12/0c599f95cfd3433bb773dba3624fb0f00e74ee4d9f7c0c0455d935cf938d/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:302f4ca7dabfe447e707d40e98520551181036c15750bdd1e73292ad8b3d8e75", size = 291878, upload-time = "2026-06-30T14:21:31.721Z" }, + { url = "https://files.pythonhosted.org/packages/78/7d/ca9f710b5bc279decae719041795a0ab2fbc02f85a5231cf2e3f4195ed1f/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:721697187f4da67dafc26f63488f463fa35ff2de8668d959fda773cccdbef0eb", size = 297428, upload-time = "2026-06-30T14:21:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0c/626a2fde3ba831ace3e092544617974dfb99d3a225cf7445987b1fd68b3e/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3da9e536546f7c62c0da595c8f71a096e0b9a80e94cfd0f329b7b200ef81e7d5", size = 414583, upload-time = "2026-06-30T14:21:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/0d/93/23daba3a074f3b181fbeeef559735cd21ab55a8bad46934ed311696812b5/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d52ec8ccdc2fd5c61749d876a9d1eb0ea6543c1676722e1e3fb9d7800852131c", size = 318441, upload-time = "2026-06-30T14:22:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/569132a43fff4e52abbdd640b76b761a773c1c1b07f5bf3576be5049e8da/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74457b43db34f984252e915828b5d1a4042a771f44e853a5643506d01562eccb", size = 295716, upload-time = "2026-06-30T14:22:24.117Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cd/a7ffa9ae8dd1903f3c17a9a2af6c531c92c3f7f7789f5c304ef77f94bd06/python_bidi-0.6.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:397f7f289eba6ce25d99dbea99f873d699bf9aa030074e7fb746d8f93c2fb6f9", size = 313112, upload-time = "2026-06-30T14:22:13.691Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e0/f252c15167d7b175e37514cf7d52cab3a244bfb0b64a5973c9ce6b33e191/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:946b7dbec4e64017680f1a66b3a8534659d889018ac83bd2abf958278e6f62b0", size = 468245, upload-time = "2026-06-30T14:22:56.534Z" }, + { url = "https://files.pythonhosted.org/packages/f9/06/7404eac40f2be2148cd0497251438e7690f553c5a09916d13d9803f8d32b/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:380b70d615647646dbe06f7d95dc30b8fdc9b596dbfa1cd3814feb9805c0c8f2", size = 573246, upload-time = "2026-06-30T14:23:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3e/49ca8310bdad3adedd6265da5c4877368ed528a0095348aa32edd8a0299e/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ffd728f1f7866ff7399906bbc17ef5cf010b90ce56a1e94937b28a1a4cf5a7d", size = 533495, upload-time = "2026-06-30T14:23:20.695Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/47cea7de2ebad67e30e5154d846f4d6a49347ca0dffcb76136be57a08226/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36f23f12d9b1c56ed8d82e11f56c6cba7bc3f614ee73374bc7772bb2270e0966", size = 499825, upload-time = "2026-06-30T14:23:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/bb/94c89a185d9c5c6154a72583eff91448f48f5f776567af217326d839d8b3/python_bidi-0.6.11-cp313-cp313-win32.whl", hash = "sha256:79df1099a08e53edb678236d4d76d8de4e3901bafc84ce1788b71f9b96547325", size = 156754, upload-time = "2026-06-30T14:23:56.033Z" }, + { url = "https://files.pythonhosted.org/packages/85/0a/7ac8da3629ca8d93a419f5250c240fc13f7d34b7c04f279c8f9a474a2be9/python_bidi-0.6.11-cp313-cp313-win_amd64.whl", hash = "sha256:f563d20481f7d316adf605bb94d5b7182acecdbc4d431d60473e9b1d526d0210", size = 160888, upload-time = "2026-06-30T14:23:46.872Z" }, + { url = "https://files.pythonhosted.org/packages/1a/dc/8d088a648845e60ee8d3d758251909320d6a51774181d2a3e72e985af0b8/python_bidi-0.6.11-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5f3e1743b2d43377c4da5d687a03430a27f5769e158a9f75a50b05e7e82f4d21", size = 267877, upload-time = "2026-06-30T14:22:45.785Z" }, + { url = "https://files.pythonhosted.org/packages/ce/53/9c3e47a0579e5a3f168bb18ecfa197d494577ac19a1dd4557ecff99e2870/python_bidi-0.6.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6d9ef69c108b31f38e1f281a55fdedad7774bc1e952a45c8c14a18e891eee397", size = 265786, upload-time = "2026-06-30T14:22:35.254Z" }, + { url = "https://files.pythonhosted.org/packages/f6/51/3fc218678ac34a99065e45fdcc0209d2dd5e1f7766132fc4c58989a96d7d/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30d543b5baf9fca5ef5ec95647aa07c5e38fc7fa0f18be0c61d1d6c0a1032c6c", size = 293177, upload-time = "2026-06-30T14:21:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/f359cfa65a6716f23c069d17b6e2b87a8c673f499753c7f9712979096f6a/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14f2d400c75e07d1154299463a3d4d14aa5565b05088b2d9b314ccd9fd6dc3a", size = 297983, upload-time = "2026-06-30T14:21:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/63/76/624bf0155d2b4fc2aa73e1276c22b66545a8d1f7280286c7e4dc443202db/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4f4a0cd24c09df748bfdc207b089c00e7af19f3151063f4cd74ac658290186b", size = 418424, upload-time = "2026-06-30T14:21:54.25Z" }, + { url = "https://files.pythonhosted.org/packages/28/cf/4a919e5be87a352ccb48b0ad2c01e2bca5d1f804d927e17df67904af62dd/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ff75d1befc335cbe85f834e81554a024f94d9b5d1dd75a5bd99af81a1cb783c", size = 319005, upload-time = "2026-06-30T14:22:04.514Z" }, + { url = "https://files.pythonhosted.org/packages/fa/25/2d9a3b0c4982ac60b8bc94b273eb7768cca18e92c5041807058fe81f4485/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:959335cd3814cb767fb832c5c71cbc838ccd9231a812ef2cb43092a216a91d5b", size = 296830, upload-time = "2026-06-30T14:22:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/1798bf13b0e8167fee6a1bd0c2ec374c8d49a6de34a205a1a67bb8d45fb2/python_bidi-0.6.11-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20ab47a4098577fc9a82816c330c89ff597c31ba69c98bc6a1b6a5737b03de05", size = 313852, upload-time = "2026-06-30T14:22:14.793Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/56a51fed9718e751a93ea3a4b894217a04cfaef0704159b400dee5fa5b4a/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60d9bf6c60c022657637f64897e63dc3f5b1c07cb7f0e1ead6150aff5150c5ab", size = 469382, upload-time = "2026-06-30T14:22:57.838Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/82979c858cba237355aee8a2a35c317ba412c4bce494e8a51ccb1c9e5321/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd564b8c583eba2d230d02d0467fd840045f08856b6524555cfff7f32af63c72", size = 573783, upload-time = "2026-06-30T14:23:10.012Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/a88d71d784144283f4e53a9849c4788c8a5fcce56a1690e073c014fa34fe/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fc3b0a6e2460f68de9ab98f71e3098bb21bb984563417ad104dec7ab08ebcadc", size = 534229, upload-time = "2026-06-30T14:23:22.108Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f1/c50692fd2cfccb563fecac7c53af00c141bd9bed09fb9cd626e0d765c53d/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3bdb64ee0a74951465cd4a761e1029e73806ff43b2fe5be98643e52da5cabb66", size = 501275, upload-time = "2026-06-30T14:23:35.379Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0f/4a83866615b86572d0854b9623e7c44de51a7d3e21b16880cfeb0fdc1b49/python_bidi-0.6.11-cp314-cp314-win32.whl", hash = "sha256:f237ebb570fd8bbe479b6967374d82b7f0b26f9452c276bdd5f793d83e7062bd", size = 157398, upload-time = "2026-06-30T14:23:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/90/78/bf20f1ab2cafaf744df006691e0f7d292f95f7c01fbaced92f5970ab3f8a/python_bidi-0.6.11-cp314-cp314-win_amd64.whl", hash = "sha256:8fbb6d222b50324fb9d49b6ff0f8566fa97b907a68c00e6622fcf34463104f4a", size = 161347, upload-time = "2026-06-30T14:23:47.921Z" }, + { url = "https://files.pythonhosted.org/packages/4d/da/cabfc8c055b53d845de46a78b641996a8ca2e4b9f7c9667fc8a54e7c9030/python_bidi-0.6.11-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:43f3e81bdd36f49171b7de6cf471086df503c29555f6f7f035ebe8f8ec1da779", size = 267716, upload-time = "2026-06-30T14:22:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/b7/0e/2839f8671a2201c5e1776e04ac48170e9b8a6c989147fe1b656d19603c7a/python_bidi-0.6.11-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7291e13496cb74fc1b71f7f1e3628586afefa531102bb4fa7af9c2d543efc3c", size = 265337, upload-time = "2026-06-30T14:22:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b8/561bfe22ac7ad3de4017542c4c6c192d0fa83a416ab2e0a66728c898e937/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ccf1fe9ecb3b02a1a11b103cd2557e2653d82c141b6e2dccec8177e6af5c4bb", size = 292362, upload-time = "2026-06-30T14:21:34.19Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/2b1159ac11e4516f566d83572481a0ca453abe132a43db4c048931676bfd/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f400a30573774a1e90c0d50d43c35d9c004afcf53de801bbfd259f84ea80f31c", size = 297068, upload-time = "2026-06-30T14:21:44.769Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d1/cf4c90a99d54ac01aeb186335dbf5a9cb89b3afbd5adc0b77b5c6f826134/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788c11c84b520ea973992cc95751d56b783ff5857df504c1347d99cacd2fcfe7", size = 416236, upload-time = "2026-06-30T14:21:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/ed/89/526f2b7be7c2e0d72dee52955538f816b7855e20d4a8e092516c13cbef78/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:879c3fba3e7511c7d01020449d970ca7d6a593f20cfc47c014d3d229a38930b2", size = 318604, upload-time = "2026-06-30T14:22:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/779464d0a6a96566f77bf8b76ec3d83cefadd8210ac758f4492942192357/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dc112e2239f69913273cbb0c050ca816b145b32037d4266c430159c5ddcdab2", size = 296857, upload-time = "2026-06-30T14:22:26.36Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bf/cba41041e3a4369be495011bd773aaedefbfef59d6c21e121e61f8ac1e2b/python_bidi-0.6.11-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19bae96ff1ee76b3a7dc962598b69426538e3021460cf85a4017437548ab6947", size = 313478, upload-time = "2026-06-30T14:22:15.968Z" }, + { url = "https://files.pythonhosted.org/packages/be/88/a4cd8dea27cab1eee8ee33be1ee2a948a7a53d37beb071811d2a0aa328d1/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:565d819fddb2bbc58c42ca5c97d73da7567f181115011e8f04c76b9d08378dcd", size = 468573, upload-time = "2026-06-30T14:22:59.11Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f3/9d8954d038e876386bb36fce4f0df61c6cf5b0664e69ab4c1914420d397e/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e6c474618a7b6a50c10007f8a9edb50def6d98d297a07a34dc2fb82c344f2b8b", size = 572916, upload-time = "2026-06-30T14:23:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/a5f8763031912748e17e0f0e06e7056b377754c3a084432f6c7f32847acd/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:73c38c604bfc01647c69ce38e5cf8b4206e2ede91ca3eb8e5d79b7409f17e0b3", size = 533968, upload-time = "2026-06-30T14:23:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/823d93e8ea9e05e77eb76eace84ea9879fe876890da616a41077b8c7ffd5/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c26cd9d81f820159026b1c99905ca12bf13892e3f6a9303359fef42fe6f39e50", size = 500671, upload-time = "2026-06-30T14:23:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/9d16556d2e0bb4a4b2132fa37fc6235e333c6bf5bccd1375ce0a15ea1db1/python_bidi-0.6.11-cp314-cp314t-win32.whl", hash = "sha256:dfbb9ba8343a60daf4ced67c11d551dafe9a3c94892c326e4c216fa2e6eca802", size = 157355, upload-time = "2026-06-30T14:23:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b6/13ea093c161da232a6eb534d420fe575ad802b0c8184860fe4f3881fbc08/python_bidi-0.6.11-cp314-cp314t-win_amd64.whl", hash = "sha256:6623683fe39b9fbf508e3069f17e8e9cab26143f9d9f89c8a8f45424c052df4f", size = 161439, upload-time = "2026-06-30T14:23:49.011Z" }, + { url = "https://files.pythonhosted.org/packages/29/27/b4878ebed0c75833629aefe0b9cbff588df86efe6068d4c100ee8d0df27b/python_bidi-0.6.11-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8eb09af209cd660fa9689f6ce9e61e73c8afa4829ae61801deea7f6e32263800", size = 271411, upload-time = "2026-06-30T14:22:50.789Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d5/080a6acda54809d12736eaf58cd71b930eba431eab78b2920727204f8a94/python_bidi-0.6.11-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7a8429d0d65232e314b4f494825c1205abcc3039f42bf7da80424a15b731709", size = 271915, upload-time = "2026-06-30T14:22:39.907Z" }, + { url = "https://files.pythonhosted.org/packages/2b/33/a076031a95627bef4e051d94ac90ff44dac687e988da4417a387e226555d/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6b3c853f99172ef22e5a16c8114cf243e351c8e70f72a894164088c2c99d9cb", size = 296494, upload-time = "2026-06-30T14:21:38.138Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/dcb312034d421f99b00d2d2b37b1a4b6ce919a3f0c70ed3aa5570b4d1bef/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:69d1f4ee17644e8aeac93a7238ee2f28d79b0180815441eb511b77e6585aa971", size = 302105, upload-time = "2026-06-30T14:21:48.328Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/25192b48e4bedf7491daa7aa50e196d06b46ffa495b039d492845173bffb/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80cfe97e2d65981be877ae5bd2338c6919a7cc1171fc308c8b8c7c306ba6ffd8", size = 419597, upload-time = "2026-06-30T14:21:58.598Z" }, + { url = "https://files.pythonhosted.org/packages/45/c0/1fb868cf41aab7e22cf01e8a4717c8447a43eadda8ccf367f3660c9005d8/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83c780f7e4c3dd3f020db75dc425567981e65c6d5571b3c0372203d0df92c834", size = 322465, upload-time = "2026-06-30T14:22:08.965Z" }, + { url = "https://files.pythonhosted.org/packages/27/41/a7903ff2829d16eae2b11dff1cffeb9739373b8fe12c27ab04dda7c5c45d/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac819cac1abb15486c48af3399a5c726e89f0977a3aff205ac162533186e756", size = 300467, upload-time = "2026-06-30T14:22:29.729Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/16f4fb6acaf5d381da8faf26a306a255e54417450674d4c004a0ba0545ae/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:63d9dcc714d549a5118ebc93b7a7c903a0c1feec8e57f5fcacf98d984b76325b", size = 318422, upload-time = "2026-06-30T14:22:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/86/04/ef0ba9e878bb5169e32bbd96e3e39ab60e947ae1757b7dd645eb4fc2e7a4/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d73a821873c52635321196cfb8d3a231d7917ca284cf2bcd9422f6deb19db7ca", size = 473278, upload-time = "2026-06-30T14:23:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/be/93/b3aa2631c4801ddd5c22f93e430ed1d570bcb634c4a217e864195cbe7574/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:bde89739a979d9eb3ac48c4882c8a42dd528a7708b0faa99b90b551588bd5f8d", size = 577704, upload-time = "2026-06-30T14:23:15.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/b0/f5b755e1e4807bb403ccad09b1ca4faca52e260f384595e9362a68e378a6/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7738cfc7ee9fcdff3fef76b40007982ce7704010a51df307c4a51d378f5ff1ba", size = 538805, upload-time = "2026-06-30T14:23:28.158Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f0/f6b9d17e3426e7b54c18d05d917982647a976233ed9348a2ef40f1a17f85/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:cf4e88a6fec81b7155a487cbbea7753a3d9a76dc4d391b4f8958b37227ef2c12", size = 505204, upload-time = "2026-06-30T14:23:41.222Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2480,6 +2728,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] +[[package]] +name = "scikit-image" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/cb/016c63f16065c2d333c8ed0337e18a5cdf9bc32d402e4f26b0db362eb0e2/scikit_image-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3278f586793176599df6a4cf48cb6beadae35c31e58dc01a98023af3dc31c78", size = 13988922, upload-time = "2025-02-18T18:04:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/ff4731289cbed63c94a0c9a5b672976603118de78ed21910d9060c82e859/scikit_image-0.25.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c311069899ce757d7dbf1d03e32acb38bb06153236ae77fcd820fd62044c063", size = 13192698, upload-time = "2025-02-18T18:04:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/39/6d/a2aadb1be6d8e149199bb9b540ccde9e9622826e1ab42fe01de4c35ab918/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be455aa7039a6afa54e84f9e38293733a2622b8c2fb3362b822d459cc5605e99", size = 14153634, upload-time = "2025-02-18T18:04:18.496Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/916e7d9ee4721031b2f625db54b11d8379bd51707afaa3e5a29aecf10bc4/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c464b90e978d137330be433df4e76d92ad3c5f46a22f159520ce0fdbea8a09", size = 14767545, upload-time = "2025-02-18T18:04:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ee/c53a009e3997dda9d285402f19226fbd17b5b3cb215da391c4ed084a1424/scikit_image-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:60516257c5a2d2f74387c502aa2f15a0ef3498fbeaa749f730ab18f0a40fd054", size = 12812908, upload-time = "2025-02-18T18:04:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" }, + { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" }, + { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" }, + { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.8.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/16/8a407688b607f86f81f8c649bf0d68a2a6d67375f18c2d660aba20f5b648/scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0", size = 12355510, upload-time = "2025-12-20T17:10:31.628Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f9/7efc088ececb6f6868fd4475e16cfafc11f242ce9ab5fc3557d78b5da0d4/scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd", size = 12056334, upload-time = "2025-12-20T17:10:34.559Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/bc7fb91fb5ff65ef42346c8b7ee8b09b04eabf89235ab7dbfdfd96cbd1ea/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953", size = 13297768, upload-time = "2025-12-20T17:10:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af", size = 13711217, upload-time = "2025-12-20T17:10:40.935Z" }, + { url = "https://files.pythonhosted.org/packages/d4/59/9637ee12c23726266b91296791465218973ce1ad3e4c56fc81e4d8e7d6e1/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d", size = 14337782, upload-time = "2025-12-20T17:10:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5c/a3e1e0860f9294663f540c117e4bf83d55e5b47c281d475cc06227e88411/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d", size = 14805997, upload-time = "2025-12-20T17:10:45.93Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c6/2eeacf173da041a9e388975f54e5c49df750757fcfc3ee293cdbbae1ea0a/scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7", size = 11878486, upload-time = "2025-12-20T17:10:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a4/a852c4949b9058d585e762a66bf7e9a2cd3be4795cd940413dfbfbb0ce79/scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581", size = 11346518, upload-time = "2025-12-20T17:10:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + [[package]] name = "scikit-learn" version = "1.7.2" @@ -2808,6 +3178,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, + { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2865,6 +3304,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tifffile" +version = "2025.5.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/06/bd0a6097da704a7a7c34a94cfd771c3ea3c2f405dd214e790d22c93f6be1/tifffile-2025.5.10-py3-none-any.whl", hash = "sha256:e37147123c0542d67bc37ba5cdd67e12ea6fbe6e86c52bee037a9eb6a064e5ad", size = 226533, upload-time = "2025-05-10T19:22:27.279Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.8.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/07/90078f49e60718d414d5440dc498301f11ff049458e65cf8d27c62a5c9d1/tifffile-2026.8.23.tar.gz", hash = "sha256:bd3c816f166f85c93329a54a0c9a1eccc9968a6a78f91d63b65d7b17675915f2", size = 446179, upload-time = "2026-08-23T18:45:05.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/38/05347ae8b1b268c068b3ca38822558038f38c6494604837d7ca3cbcceb9c/tifffile-2026.8.23-py3-none-any.whl", hash = "sha256:a03045afce67d97cb4ef56a1cd47eae6ab8115c7e84cfedeb5cadbfbb6d14fbe", size = 273795, upload-time = "2026-08-23T18:45:04.466Z" }, +] + [[package]] name = "tinycss2" version = "1.5.1" @@ -3180,6 +3671,7 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "easyocr" }, { name = "graphviz" }, { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3204,6 +3696,7 @@ graphviz = [ { name = "graphviz" }, ] samvg = [ + { name = "easyocr" }, { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3231,6 +3724,7 @@ vision = [ requires-dist = [ { name = "anthropic", specifier = ">=0.47.0" }, { name = "cairosvg", specifier = ">=2.7.0" }, + { name = "easyocr", marker = "extra == 'samvg'", specifier = ">=1.7.2" }, { name = "google-genai", specifier = ">=1.68.0" }, { name = "graphviz", marker = "extra == 'graphviz'", specifier = ">=0.21" }, { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.10.8" }, From 6afcb0d24114e36235d526e082ae5d65b8ddefd2 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 11:48:51 +0200 Subject: [PATCH 04/57] feat: use Qwen for SAMVG text recognition --- pyproject.toml | 5 +- src/vectrify/refine/samvg.py | 146 +++++++--- tests/refine/test_samvg.py | 67 ++++- uv.lock | 498 +---------------------------------- 4 files changed, 166 insertions(+), 550 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9efcee20..57b8a0da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ vision = [ "scikit-learn>=1.3.0", "torch>=2.0.0", "torchvision>=0.28.0", - "transformers>=4.40.0", + "transformers>=4.49.0", ] # The CUDA extension is shipped in platform-specific wheels. Installing this # extra is deliberately sufficient for the SAMVG seed even on machines that @@ -65,8 +65,7 @@ samvg = [ "scikit-learn>=1.3.0", "torch>=2.0.0", "torchvision>=0.28.0", - "transformers>=4.40.0", - "easyocr>=1.7.2", + "transformers>=4.49.0", ] graphviz = [ "graphviz>=0.21", diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 69abd37a..f6950281 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -10,13 +10,15 @@ import io import itertools +import json import logging import math import os +import re import xml.etree.ElementTree as ET from collections import defaultdict from dataclasses import dataclass -from typing import cast +from typing import Any, cast from xml.sax.saxutils import escape import numpy as np @@ -33,6 +35,13 @@ # photo seed before that image-aware test could evaluate them. SAMVG_PRED_IOU_THRESH = 0.0 SAMVG_STABILITY_SCORE_THRESH = 0.0 +# The SAMVG seed only needs OCR once and does it after SAM has released its +# automatic-mask pipeline. This is a real VLM pass, not a separate small OCR +# detector: it can decide which visible labels deserve editable text and place +# them in the source coordinate system. +SAMVG_OCR_MODEL = os.environ.get( + "VECTRIFY_SAMVG_OCR_MODEL", "Qwen/Qwen2.5-VL-3B-Instruct" +) @dataclass(frozen=True) @@ -74,53 +83,116 @@ def _text_colour(pixels: np.ndarray) -> tuple[int, int, int]: return cast(tuple[int, int, int], tuple(int(value) for value in np.rint(colour))) -def detect_text(image: Image.Image, *, confidence: float = 0.7) -> list[TextLayer]: - """Read editable words with EasyOCR's Torch detector and recogniser. +def _ocr_json(response: str) -> list[dict[str, object]]: + """Decode the strict JSON array requested from the vision-language model.""" + match = re.search(r"\[[\s\S]*\]", response) + if match is None: + return [] + try: + parsed = json.loads(match.group()) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + return [item for item in parsed if isinstance(item, dict)] - The detector is deliberately conservative. The SVG font is necessarily an - approximation of the source font, so uncertain single characters remain - with the normal SAMVG filled-path pipeline. + +def detect_text(image: Image.Image, *, confidence: float = 0.8) -> list[TextLayer]: + """Read editable text using Qwen2.5-VL's 3B Torch model. + + It returns content and source-pixel bounding boxes in one inference pass. + We keep only the VLM's high-confidence multi-character labels: a guessed + font is worse than the normal SAMVG filled-path representation. """ try: - import easyocr import torch + from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration except ImportError as exc: # pragma: no cover - installation-specific raise ImportError( "SAMVG OCR requires the samvg extra. Install 'vectrify[samvg]'." ) from exc - reader = easyocr.Reader(["en"], gpu=torch.cuda.is_available(), verbose=False) source = np.asarray(image.convert("RGB")) + device = "cuda" if torch.cuda.is_available() else "cpu" + dtype = torch.bfloat16 if device == "cuda" else torch.float32 + prompt = ( + "Read visible text in this image. Return only a JSON array. Each entry " + 'must be {"text": string, "box": [left, top, right, bottom], ' + '"confidence": number}. Boxes must use this image\'s pixel ' + "coordinates. Include only clearly readable labels of at least two " + "characters, and do not describe icons, logos, or non-text shapes." + ) + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": image}, + {"type": "text", "text": prompt}, + ], + } + ] + processor = AutoProcessor.from_pretrained(SAMVG_OCR_MODEL) + # Transformers currently exposes a descriptor mismatch between this model + # class and GenerationMixin to Pyrefly; runtime generation is the normal + # PreTrainedModel API. + model: Any = Qwen2_5_VLForConditionalGeneration.from_pretrained( + SAMVG_OCR_MODEL, torch_dtype=dtype + ).to(device) detected: list[TextLayer] = [] - for box, text, score in reader.readtext(source, detail=1, paragraph=False): - if float(score) < confidence or len(text.strip()) < 2: - continue - corners = np.asarray(box, dtype=np.float32) - if corners.shape != (4, 2): - continue - x, y = corners.min(axis=0) - right, bottom = corners.max(axis=0) - width, height = float(right - x), float(bottom - y) - if width < 4 or height < 4: - continue - direction = corners[1] - corners[0] - angle = math.degrees(math.atan2(float(direction[1]), float(direction[0]))) - crop = source[ - max(0, math.floor(y)) : math.ceil(bottom), - max(0, math.floor(x)) : math.ceil(right), - ] - if not crop.size: - continue - detected.append( - TextLayer( - text=text.strip(), - x=float(x), - y=float(y), - width=width, - height=height, - colour=_text_colour(crop), - angle=angle, - ) + try: + chat = processor.apply_chat_template( + messages, tokenize=False, add_generation_prompt=True ) + inputs = processor( + text=[chat], images=[image], padding=True, return_tensors="pt" + ).to(device) + with torch.inference_mode(): + output = model.generate(**inputs, max_new_tokens=768, do_sample=False) + generated = output[:, inputs.input_ids.shape[1] :] + response = processor.batch_decode( + generated, skip_special_tokens=True, clean_up_tokenization_spaces=False + )[0] + for entry in _ocr_json(response): + text = entry.get("text") + box = entry.get("box") + score = entry.get("confidence") + if ( + not isinstance(text, str) + or not isinstance(box, list) + or len(box) != 4 + or not isinstance(score, (int, float)) + or float(score) < confidence + or len(text.strip()) < 2 + ): + continue + try: + x, y, right, bottom = (float(value) for value in box) + except (TypeError, ValueError): + continue + x, y = max(0.0, x), max(0.0, y) + right = min(float(image.width), right) + bottom = min(float(image.height), bottom) + width, height = right - x, bottom - y + if width < 4 or height < 4: + continue + crop = source[ + math.floor(y) : math.ceil(bottom), math.floor(x) : math.ceil(right) + ] + if not crop.size: + continue + detected.append( + TextLayer( + text=text.strip(), + x=x, + y=y, + width=width, + height=height, + colour=_text_colour(crop), + ) + ) + finally: + del model + if torch.cuda.is_available(): + torch.cuda.empty_cache() log.info("SAMVG OCR: retained %d editable text layer(s).", len(detected)) return detected diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index e6499add..33d47ef4 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -1,5 +1,6 @@ import sys import xml.etree.ElementTree as ET +from contextlib import nullcontext from types import SimpleNamespace import numpy as np @@ -26,26 +27,64 @@ def test_detect_text_retains_high_confidence_editable_words(monkeypatch): - class Reader: - def __init__(self, languages, *, gpu, verbose): - assert languages == ["en"] - assert gpu is True - assert verbose is False - - def readtext(self, source, **kwargs): - assert source.shape == (16, 32, 3) - assert kwargs == {"detail": 1, "paragraph": False} + class Inputs(dict): + input_ids = SimpleNamespace(shape=(1, 4)) + + def to(self, device): + assert device == "cuda" + return self + + class Processor: + def apply_chat_template(self, messages, **kwargs): + assert messages[0]["content"][0]["image"].size == (32, 16) + assert kwargs == {"tokenize": False, "add_generation_prompt": True} + return "prompt" + + def __call__(self, **kwargs): + assert kwargs["text"] == ["prompt"] + assert kwargs["images"][0].size == (32, 16) + return Inputs() + + def batch_decode(self, generated, **kwargs): + assert generated.shape == (1, 1) + assert kwargs == { + "skip_special_tokens": True, + "clean_up_tokenization_spaces": False, + } return [ - ([[2, 3], [20, 3], [20, 11], [2, 11]], "Cats & dogs", 0.94), - ([[2, 12], [4, 12], [4, 14], [2, 14]], "I", 0.99), - ([[2, 3], [20, 3], [20, 11], [2, 11]], "blur", 0.2), + '[{"text":"Cats & dogs","box":[2,3,20,11],"confidence":0.94},' + '{"text":"I","box":[2,12,4,14],"confidence":0.99},' + '{"text":"blur","box":[2,3,20,11],"confidence":0.2}]' ] - monkeypatch.setitem(sys.modules, "easyocr", SimpleNamespace(Reader=Reader)) + class Model: + def to(self, device): + assert device == "cuda" + return self + + def generate(self, **kwargs): + assert kwargs == {"max_new_tokens": 768, "do_sample": False} + return np.zeros((1, 5), dtype=int) + + monkeypatch.setitem( + sys.modules, + "transformers", + SimpleNamespace( + AutoProcessor=SimpleNamespace(from_pretrained=lambda _model: Processor()), + Qwen2_5_VLForConditionalGeneration=SimpleNamespace( + from_pretrained=lambda _model, **_kwargs: Model() + ), + ), + ) monkeypatch.setitem( sys.modules, "torch", - SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)), + SimpleNamespace( + bfloat16="bf16", + float32="float32", + cuda=SimpleNamespace(is_available=lambda: True, empty_cache=lambda: None), + inference_mode=nullcontext, + ), ) layers = detect_text(Image.new("RGB", (32, 16), "white")) diff --git a/uv.lock b/uv.lock index 90f786e2..30b169d9 100644 --- a/uv.lock +++ b/uv.lock @@ -665,33 +665,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "easyocr" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ninja" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "opencv-python-headless" }, - { name = "pillow" }, - { name = "pyclipper" }, - { name = "python-bidi" }, - { name = "pyyaml" }, - { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "shapely" }, - { name = "torch" }, - { name = "torchvision" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/84/4a2cab0e6adde6a85e7ba543862e5fc0250c51f3ac721a078a55cdcff250/easyocr-1.7.2-py3-none-any.whl", hash = "sha256:5be12f9b0e595d443c9c3d10b0542074b50f0ec2d98b141a109cd961fd1c177c", size = 2870178, upload-time = "2024-09-24T11:34:43.554Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -926,21 +899,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] -[[package]] -name = "imageio" -version = "2.37.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3", size = 390173, upload-time = "2026-07-20T05:26:11.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6", size = 318000, upload-time = "2026-07-20T05:26:09.874Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -1194,18 +1152,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] -[[package]] -name = "lazy-loader" -version = "0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1514,32 +1460,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] -[[package]] -name = "ninja" -version = "1.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/73/79a0b22fc731989c708068427579e840a6cf4e937fe7ae5c5d0b7356ac22/ninja-1.13.0.tar.gz", hash = "sha256:4a40ce995ded54d9dc24f8ea37ff3bf62ad192b547f6c7126e7e25045e76f978", size = 242558, upload-time = "2025-08-11T15:10:19.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/74/d02409ed2aa865e051b7edda22ad416a39d81a84980f544f8de717cab133/ninja-1.13.0-py3-none-macosx_10_9_universal2.whl", hash = "sha256:fa2a8bfc62e31b08f83127d1613d10821775a0eb334197154c4d6067b7068ff1", size = 310125, upload-time = "2025-08-11T15:09:50.971Z" }, - { url = "https://files.pythonhosted.org/packages/8e/de/6e1cd6b84b412ac1ef327b76f0641aeb5dcc01e9d3f9eee0286d0c34fd93/ninja-1.13.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630", size = 177467, upload-time = "2025-08-11T15:09:52.767Z" }, - { url = "https://files.pythonhosted.org/packages/c8/83/49320fb6e58ae3c079381e333575fdbcf1cca3506ee160a2dcce775046fa/ninja-1.13.0-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:be7f478ff9f96a128b599a964fc60a6a87b9fa332ee1bd44fa243ac88d50291c", size = 187834, upload-time = "2025-08-11T15:09:54.115Z" }, - { url = "https://files.pythonhosted.org/packages/56/c7/ba22748fb59f7f896b609cd3e568d28a0a367a6d953c24c461fe04fc4433/ninja-1.13.0-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:60056592cf495e9a6a4bea3cd178903056ecb0943e4de45a2ea825edb6dc8d3e", size = 202736, upload-time = "2025-08-11T15:09:55.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/22/d1de07632b78ac8e6b785f41fa9aad7a978ec8c0a1bf15772def36d77aac/ninja-1.13.0-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1c97223cdda0417f414bf864cfb73b72d8777e57ebb279c5f6de368de0062988", size = 179034, upload-time = "2025-08-11T15:09:57.394Z" }, - { url = "https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa", size = 180716, upload-time = "2025-08-11T15:09:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/54/28/938b562f9057aaa4d6bfbeaa05e81899a47aebb3ba6751e36c027a7f5ff7/ninja-1.13.0-py3-none-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4be9c1b082d244b1ad7ef41eb8ab088aae8c109a9f3f0b3e56a252d3e00f42c1", size = 146843, upload-time = "2025-08-11T15:10:00.046Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fb/d06a3838de4f8ab866e44ee52a797b5491df823901c54943b2adb0389fbb/ninja-1.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6739d3352073341ad284246f81339a384eec091d9851a886dfa5b00a6d48b3e2", size = 154402, upload-time = "2025-08-11T15:10:01.657Z" }, - { url = "https://files.pythonhosted.org/packages/31/bf/0d7808af695ceddc763cf251b84a9892cd7f51622dc8b4c89d5012779f06/ninja-1.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:11be2d22027bde06f14c343f01d31446747dbb51e72d00decca2eb99be911e2f", size = 552388, upload-time = "2025-08-11T15:10:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/9d/70/c99d0c2c809f992752453cce312848abb3b1607e56d4cd1b6cded317351a/ninja-1.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aa45b4037b313c2f698bc13306239b8b93b4680eb47e287773156ac9e9304714", size = 472501, upload-time = "2025-08-11T15:10:04.735Z" }, - { url = "https://files.pythonhosted.org/packages/9f/43/c217b1153f0e499652f5e0766da8523ce3480f0a951039c7af115e224d55/ninja-1.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f8e1e8a1a30835eeb51db05cf5a67151ad37542f5a4af2a438e9490915e5b72", size = 638280, upload-time = "2025-08-11T15:10:06.512Z" }, - { url = "https://files.pythonhosted.org/packages/8c/45/9151bba2c8d0ae2b6260f71696330590de5850e5574b7b5694dce6023e20/ninja-1.13.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:3d7d7779d12cb20c6d054c61b702139fd23a7a964ec8f2c823f1ab1b084150db", size = 642420, upload-time = "2025-08-11T15:10:08.35Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/95752eb635bb8ad27d101d71bef15bc63049de23f299e312878fc21cb2da/ninja-1.13.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:d741a5e6754e0bda767e3274a0f0deeef4807f1fec6c0d7921a0244018926ae5", size = 585106, upload-time = "2025-08-11T15:10:09.818Z" }, - { url = "https://files.pythonhosted.org/packages/c1/31/aa56a1a286703800c0cbe39fb4e82811c277772dc8cd084f442dd8e2938a/ninja-1.13.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:e8bad11f8a00b64137e9b315b137d8bb6cbf3086fbdc43bf1f90fd33324d2e96", size = 707138, upload-time = "2025-08-11T15:10:11.366Z" }, - { url = "https://files.pythonhosted.org/packages/34/6f/5f5a54a1041af945130abdb2b8529cbef0cdcbbf9bcf3f4195378319d29a/ninja-1.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b4f2a072db3c0f944c32793e91532d8948d20d9ab83da9c0c7c15b5768072200", size = 581758, upload-time = "2025-08-11T15:10:13.295Z" }, - { url = "https://files.pythonhosted.org/packages/95/97/51359c77527d45943fe7a94d00a3843b81162e6c4244b3579fe8fc54cb9c/ninja-1.13.0-py3-none-win32.whl", hash = "sha256:8cfbb80b4a53456ae8a39f90ae3d7a2129f45ea164f43fadfa15dc38c4aef1c9", size = 267201, upload-time = "2025-08-11T15:10:15.158Z" }, - { url = "https://files.pythonhosted.org/packages/29/45/c0adfbfb0b5895aa18cec400c535b4f7ff3e52536e0403602fc1a23f7de9/ninja-1.13.0-py3-none-win_amd64.whl", hash = "sha256:fb8ee8719f8af47fed145cced4a85f0755dd55d45b2bddaf7431fa89803c5f3e", size = 309975, upload-time = "2025-08-11T15:10:16.697Z" }, - { url = "https://files.pythonhosted.org/packages/df/93/a7b983643d1253bb223234b5b226e69de6cda02b76cdca7770f684b795f5/ninja-1.13.0-py3-none-win_arm64.whl", hash = "sha256:3c0b40b1f0bba764644385319028650087b4c1b18cdfa6f45cb39a3669b81aa9", size = 290806, upload-time = "2025-08-11T15:10:18.018Z" }, -] - [[package]] name = "numpy" version = "2.2.6" @@ -1930,27 +1850,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/ca/53357e460a1172e831ecbe43dd0c37342b7211a1eb09f4cf21a412adbbdf/openai-2.49.0-py3-none-any.whl", hash = "sha256:b694201eaa42a1ccf2aa125fe29458150108fb22df1abfb55d7188599da81d8c", size = 1648589, upload-time = "2026-07-27T22:51:38Z" }, ] -[[package]] -name = "opencv-python-headless" -version = "5.0.0.93" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, - { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, - { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, - { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -2099,49 +1998,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] -[[package]] -name = "pyclipper" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/9f/a10173d32ecc2ce19a04d018163f3ca22a04c0c6ad03b464dcd32f9152a8/pyclipper-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bafad70d2679c187120e8c44e1f9a8b06150bad8c0aecf612ad7dfbfa9510f73", size = 264510, upload-time = "2025-12-01T13:14:46.551Z" }, - { url = "https://files.pythonhosted.org/packages/e0/c2/5490ddc4a1f7ceeaa0258f4266397e720c02db515b2ca5bc69b85676f697/pyclipper-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b74a9dd44b22a7fd35d65fb1ceeba57f3817f34a97a28c3255556362e491447", size = 139498, upload-time = "2025-12-01T13:14:48.31Z" }, - { url = "https://files.pythonhosted.org/packages/3b/0a/bea9102d1d75634b1a5702b0e92982451a1eafca73c4845d3dbe27eba13d/pyclipper-1.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a4d2736fb3c42e8eb1d38bf27a720d1015526c11e476bded55138a977c17d9d", size = 970974, upload-time = "2025-12-01T13:14:49.799Z" }, - { url = "https://files.pythonhosted.org/packages/8b/1b/097f8776d5b3a10eb7b443b632221f4ed825d892e79e05682f4b10a1a59c/pyclipper-1.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3b3630051b53ad2564cb079e088b112dd576e3d91038338ad1cc7915e0f14dc", size = 943315, upload-time = "2025-12-01T13:14:51.266Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/17d6a3f1abf0f368d58f2309e80ee3761afb1fd1342f7780ab32ba4f0b1d/pyclipper-1.4.0-cp310-cp310-win32.whl", hash = "sha256:8d42b07a2f6cfe2d9b87daf345443583f00a14e856927782fde52f3a255e305a", size = 95286, upload-time = "2025-12-01T13:14:52.922Z" }, - { url = "https://files.pythonhosted.org/packages/53/ca/b30138427ed122ec9b47980b943164974a2ec606fa3f71597033b9a9f9a6/pyclipper-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:6a97b961f182b92d899ca88c1bb3632faea2e00ce18d07c5f789666ebb021ca4", size = 104227, upload-time = "2025-12-01T13:14:54.013Z" }, - { url = "https://files.pythonhosted.org/packages/de/e3/64cf7794319b088c288706087141e53ac259c7959728303276d18adc665d/pyclipper-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:adcb7ca33c5bdc33cd775e8b3eadad54873c802a6d909067a57348bcb96e7a2d", size = 264281, upload-time = "2025-12-01T13:14:55.47Z" }, - { url = "https://files.pythonhosted.org/packages/34/cd/44ec0da0306fa4231e76f1c2cb1fa394d7bde8db490a2b24d55b39865f69/pyclipper-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fd24849d2b94ec749ceac7c34c9f01010d23b6e9d9216cf2238b8481160e703d", size = 139426, upload-time = "2025-12-01T13:14:56.683Z" }, - { url = "https://files.pythonhosted.org/packages/ad/88/d8f6c6763ea622fe35e19c75d8b39ed6c55191ddc82d65e06bc46b26cb8e/pyclipper-1.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b6c8d75ba20c6433c9ea8f1a0feb7e4d3ac06a09ad1fd6d571afc1ddf89b869", size = 989649, upload-time = "2025-12-01T13:14:58.28Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e9/ea7d68c8c4af3842d6515bedcf06418610ad75f111e64c92c1d4785a1513/pyclipper-1.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e29d7443d7cc0e83ee9daf43927730386629786d00c63b04fe3b53ac01462c", size = 962842, upload-time = "2025-12-01T13:15:00.044Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/0b4a272d8726e51ab05e2b933d8cc47f29757fb8212e38b619e170e6015c/pyclipper-1.4.0-cp311-cp311-win32.whl", hash = "sha256:a8d2b5fb75ebe57e21ce61e79a9131edec2622ff23cc665e4d1d1f201bc1a801", size = 95098, upload-time = "2025-12-01T13:15:01.359Z" }, - { url = "https://files.pythonhosted.org/packages/3a/76/4901de2919198bb2bd3d989f86d4a1dff363962425bb2d63e24e6c990042/pyclipper-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:e9b973467d9c5fa9bc30bb6ac95f9f4d7c3d9fc25f6cf2d1cc972088e5955c01", size = 104362, upload-time = "2025-12-01T13:15:02.439Z" }, - { url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/cbce7d47de1e6458f66a4d999b091640134deb8f2c7351eab993b70d2e10/pyclipper-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e", size = 264342, upload-time = "2025-12-01T13:15:12.697Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cc/742b9d69d96c58ac156947e1b56d0f81cbacbccf869e2ac7229f2f86dc4e/pyclipper-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a", size = 139839, upload-time = "2025-12-01T13:15:13.911Z" }, - { url = "https://files.pythonhosted.org/packages/db/48/dd301d62c1529efdd721b47b9e5fb52120fcdac5f4d3405cfc0d2f391414/pyclipper-1.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f", size = 972142, upload-time = "2025-12-01T13:15:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/07/bf/d493fd1b33bb090fa64e28c1009374d5d72fa705f9331cd56517c35e381e/pyclipper-1.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1", size = 952789, upload-time = "2025-12-01T13:15:17.042Z" }, - { url = "https://files.pythonhosted.org/packages/cf/88/b95ea8ea21ddca34aa14b123226a81526dd2faaa993f9aabd3ed21231604/pyclipper-1.4.0-cp313-cp313-win32.whl", hash = "sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c", size = 94817, upload-time = "2025-12-01T13:15:18.724Z" }, - { url = "https://files.pythonhosted.org/packages/ba/42/0a1920d276a0e1ca21dc0d13ee9e3ba10a9a8aa3abac76cd5e5a9f503306/pyclipper-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832", size = 104007, upload-time = "2025-12-01T13:15:19.763Z" }, - { url = "https://files.pythonhosted.org/packages/1a/20/04d58c70f3ccd404f179f8dd81d16722a05a3bf1ab61445ee64e8218c1f8/pyclipper-1.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c", size = 265167, upload-time = "2025-12-01T13:15:20.844Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/a570c1abe69b7260ca0caab4236ce6ea3661193ebf8d1bd7f78ccce537a5/pyclipper-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e", size = 139966, upload-time = "2025-12-01T13:15:22.036Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/e0859e54adabdde8a24a29d3f525ebb31c71ddf2e8d93edce83a3c212ffc/pyclipper-1.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9", size = 968216, upload-time = "2025-12-01T13:15:23.18Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6b/e3c4febf0a35ae643ee579b09988dd931602b5bf311020535fd9e5b7e715/pyclipper-1.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39", size = 954198, upload-time = "2025-12-01T13:15:24.522Z" }, - { url = "https://files.pythonhosted.org/packages/fc/74/728efcee02e12acb486ce9d56fa037120c9bf5b77c54bbdbaa441c14a9d9/pyclipper-1.4.0-cp314-cp314-win32.whl", hash = "sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b", size = 96951, upload-time = "2025-12-01T13:15:25.79Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d7/7f4354e69f10a917e5c7d5d72a499ef2e10945312f5e72c414a0a08d2ae4/pyclipper-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826", size = 106782, upload-time = "2025-12-01T13:15:26.945Z" }, - { url = "https://files.pythonhosted.org/packages/63/60/fc32c7a3d7f61a970511ec2857ecd09693d8ac80d560ee7b8e67a6d268c9/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303", size = 269880, upload-time = "2025-12-01T13:15:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/49/df/c4a72d3f62f0ba03ec440c4fff56cd2d674a4334d23c5064cbf41c9583f6/pyclipper-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9", size = 141706, upload-time = "2025-12-01T13:15:30.134Z" }, - { url = "https://files.pythonhosted.org/packages/c5/0b/cf55df03e2175e1e2da9db585241401e0bc98f76bee3791bed39d0313449/pyclipper-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286", size = 105308, upload-time = "2025-12-01T13:15:31.225Z" }, - { url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608, upload-time = "2025-12-01T13:15:32.69Z" }, - { url = "https://files.pythonhosted.org/packages/18/59/81050abdc9e5b90ffc2c765738c5e40e9abd8e44864aaa737b600f16c562/pyclipper-1.4.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98b2a40f98e1fc1b29e8a6094072e7e0c7dfe901e573bf6cfc6eb7ce84a7ae87", size = 126495, upload-time = "2025-12-01T13:15:33.743Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -2350,110 +2206,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] -[[package]] -name = "python-bidi" -version = "0.6.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/f168f2c3151aa05b9f9c9b2f7767bc8e06a133ea822c231ab497d4f36833/python_bidi-0.6.11.tar.gz", hash = "sha256:034090c597af250d699299d7e7f1e83eb016f9e47b3b707bd89ab2bdec77bce0", size = 57647, upload-time = "2026-06-30T14:23:42.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/54/439befffac4b2d14c965928175cdf45586680824adf01fc19a6bcc7b0342/python_bidi-0.6.11-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a9aa2890661b238730e680ccd6eb06f4da625b2dbc1730052dae6f2d88957192", size = 269604, upload-time = "2026-06-30T14:22:41.007Z" }, - { url = "https://files.pythonhosted.org/packages/77/fc/723f92efcbfb326b9091a6acc1385bbec4e0a3f5850fc360ee048ad9c9ab/python_bidi-0.6.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41c7d1914c1f33954aa2ab0e2c309be5d1d4afc75ce524762eaab4dd825c5ab8", size = 269850, upload-time = "2026-06-30T14:22:30.786Z" }, - { url = "https://files.pythonhosted.org/packages/36/9a/8dc0e4613acdcca5f14efe327f6fc0f5f0908288a3c6fc7d1d368ee96c52/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57aa56b1eca5f63ebdd25fe8fad02eab7797fc45ad1efc5eed2a830e2bd2038d", size = 295625, upload-time = "2026-06-30T14:21:27.876Z" }, - { url = "https://files.pythonhosted.org/packages/a8/66/812b9c6ed40021c5e1a04d5a65c347c339f89057642e48bc2b8bead0c83a/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5695d87969fed5b3799b8a98848cb04fe2873fded46efe9f3f2f341efd1b829", size = 300843, upload-time = "2026-06-30T14:21:39.289Z" }, - { url = "https://files.pythonhosted.org/packages/27/78/f6aedee9fdafd59faba64bc053efcee2ac9ad7d3971311095bb36d542c00/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da51a3a2940478219f19249fcf7cd45e8ddc197982be22efa43c4763e9d2eb57", size = 418888, upload-time = "2026-06-30T14:21:49.447Z" }, - { url = "https://files.pythonhosted.org/packages/47/32/cdb85b5aca0c5055a351aee768816ac43bbde6a0f9d2b71866ed41e93211/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56807e0dde88d5ab96880c9d668bda7bca1df83cd30d20cbff5d6b6e6c1e9276", size = 321304, upload-time = "2026-06-30T14:21:59.874Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/c4c4c790d697464c77f87e7741c2bc438d761d7783950415d6e238f7a7c0/python_bidi-0.6.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1115f3f02eb836b39e0c986a6b9e92c4377a1e5a680409650b02ec6b1a795e6", size = 299426, upload-time = "2026-06-30T14:22:20.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/5e/84ea7bb7dfdc232f0bc2a58f957320b289a01ad6010098c245d4abad092a/python_bidi-0.6.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ad5f712a32be30d28eb96e119e85818747a43cea253de6c3160b464b62a5619", size = 316772, upload-time = "2026-06-30T14:22:10.044Z" }, - { url = "https://files.pythonhosted.org/packages/18/8e/295372d9d17160d74babae10014a67527917bbb7cde1905d01174d6efc1f/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8013c1e6267a929be98d10c3a617171709c8226bd33c6180ec65e560c35b6df8", size = 471770, upload-time = "2026-06-30T14:22:52.148Z" }, - { url = "https://files.pythonhosted.org/packages/3f/78/28623ac1401586cc469f67c1fa7254ba1e1507c1a34a6c75b90dd5e8fc3a/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ff675d036823bff05a2e0f8cdb13f5414274ff517d13b0d57c15527015eb6acb", size = 576384, upload-time = "2026-06-30T14:23:04.545Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d4/36e34e27181df73ef9ed7091aa710ca052df2e76bc20cdfca87f3be67c5c/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fa2848c3116d619870d114471fcd4a9f1aa43587a002111d7af1e6582f1b57e9", size = 537321, upload-time = "2026-06-30T14:23:16.754Z" }, - { url = "https://files.pythonhosted.org/packages/f6/61/6db9deaa5f8213506be2ea79279fdf9397e4bda7be42c6f8d638ef334a86/python_bidi-0.6.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:493655043ebea6fec0edb76053bdad46f1f7352a759ca4deb733f13b3c09839c", size = 503988, upload-time = "2026-06-30T14:23:29.455Z" }, - { url = "https://files.pythonhosted.org/packages/6d/65/9b1bc1b056b31bdd658906c95ea40e188ae74c61b1ce89e978fe587a7bb1/python_bidi-0.6.11-cp310-cp310-win32.whl", hash = "sha256:62a6b700f3d7a2c4d52a5dccca765711a2414e734360b00365e155698a26e461", size = 158574, upload-time = "2026-06-30T14:23:52.599Z" }, - { url = "https://files.pythonhosted.org/packages/98/f2/7507dc1b0e513e46de273875099bdc83a18f50f9da176d02dbc25612bc22/python_bidi-0.6.11-cp310-cp310-win_amd64.whl", hash = "sha256:fccd1808bb427d6a6d34168461bef551faa93ce3dda489fcef9073bcd9b34a5e", size = 163124, upload-time = "2026-06-30T14:23:43.648Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/7f942cdb3cc948a369bfe2530343f2d650aa17bb04b0b959834919f699a4/python_bidi-0.6.11-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:56f27c1edfd15c12c9c348378ccd79166930d720cf316b1181a0a0ade2146253", size = 269449, upload-time = "2026-06-30T14:22:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/ee/6e/af3e17cb48b87176c209ac4271c8a9aaad8c33f5535739b58336222e69af/python_bidi-0.6.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a52f7ad9ef9091e81869e5d255e796755ccf542ade14dda17647cb7d7ffe1b9c", size = 269840, upload-time = "2026-06-30T14:22:31.833Z" }, - { url = "https://files.pythonhosted.org/packages/91/62/f7303a11e8286b2219088bb863974398a1a9f117444e78bbfcefaef7bc14/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4ebc24ac38e50676f65daf7ba6c568789660cb60d6dcf2606d4310dba826721", size = 295322, upload-time = "2026-06-30T14:21:29.37Z" }, - { url = "https://files.pythonhosted.org/packages/29/38/930e63c374133760f69159da36a7aba98368ade44ae708215addc4079d91/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:969ee7db3e169fcc0b2d2d094826e03cc5798dfd6b3571a340ea883672396cb1", size = 300777, upload-time = "2026-06-30T14:21:40.302Z" }, - { url = "https://files.pythonhosted.org/packages/8d/af/f92408a2882ed7c94c3df9038df48400a5adc345e963d488bf716ad25358/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d496f9fc21b7457e12395e54088ab99776966c663b4cd4a74770c7a6418ab59", size = 419532, upload-time = "2026-06-30T14:21:50.531Z" }, - { url = "https://files.pythonhosted.org/packages/24/0c/5fb11159f50e9a898862fa40fe98b8994eccd224189bb1c805ecfae66977/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad5d9b8e8a6c330208eba413db506de58f21dbf88a1f1d5d75ef5f9e0e714adf", size = 320985, upload-time = "2026-06-30T14:22:01.157Z" }, - { url = "https://files.pythonhosted.org/packages/16/d3/6bd8b189219ec128263b7277b1cdaed0cf014199c61e05793be2d9cb0456/python_bidi-0.6.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acccb6d90e694684db2d314db2e2b0d3b8949bf1cfaec6d9808a8889f548add7", size = 299186, upload-time = "2026-06-30T14:22:21.702Z" }, - { url = "https://files.pythonhosted.org/packages/36/9e/b7da9c128f5ed867a62cb8112602bc7a6b6af783bbac18ca303a8a09b868/python_bidi-0.6.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b5ee069001bf7f4fff109598a9396caa96bb35e1b906b2c6d1bab9f9b2c4bd", size = 316856, upload-time = "2026-06-30T14:22:11.328Z" }, - { url = "https://files.pythonhosted.org/packages/72/a9/f5e4c286ae22fff569eb7aeeec7a5342e26e0ed1ac8202c48cf273932b28/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:619ee3fe03daec8d3ce12239f0c22455676a063b2bcde361caecd788fae5b8d5", size = 471488, upload-time = "2026-06-30T14:22:53.429Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/47d84a333ee00db0cff74fde2aa39e01953abb360d14ffed70ad9195af26/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:af7711cc6eeeadcb1aae877e0736a68e111c73a29562da6106c5e2fbb4dd83b2", size = 576451, upload-time = "2026-06-30T14:23:05.961Z" }, - { url = "https://files.pythonhosted.org/packages/50/b1/1c158a64d745e4916fbc39c6ad700c5b2cf3b4456f710a62f625fba0694f/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b2c759e13ebb81edaac3041697328bb1ca8433b55281723879f6b54e74881240", size = 537476, upload-time = "2026-06-30T14:23:18.118Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7c/eeaad2247b29f736cda2632d14c5959940a4b4e8e098559cd2a4065356eb/python_bidi-0.6.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b433840a924c8788f0abbda15d22c71dc636e2078d7d3ba39369cebe4bef74b8", size = 503885, upload-time = "2026-06-30T14:23:31.134Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d0/ec71ea3e29cc745580ac5477bbf3c9235782ab0c7bc08ac065e4c7cb12ec/python_bidi-0.6.11-cp311-cp311-win32.whl", hash = "sha256:8b6b7fce8f47578be9aebf5a0b6b2d6c157b4e97af7586ecf13bfca5d128deea", size = 158704, upload-time = "2026-06-30T14:23:53.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/98/0458bf0adcf09f1766e069f6d1265d83b8354146e689927f0d14451bf4e2/python_bidi-0.6.11-cp311-cp311-win_amd64.whl", hash = "sha256:555cdf9303c40bae1ab512ca427f1f0316a574bc0a48db22eec76ec0fd1213cf", size = 163236, upload-time = "2026-06-30T14:23:44.741Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ad/e2ff0e5077de577211d7d4fd6a436a97d903d6c65e8deb4c958de901b0eb/python_bidi-0.6.11-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:83ee87feb5eafc0442e1db0014dad20d52a2a7a140b6cddc8f7bc65918f0a7b4", size = 267279, upload-time = "2026-06-30T14:22:43.34Z" }, - { url = "https://files.pythonhosted.org/packages/08/19/776b39e47e0bde27000fc2e68c2dd0bad023d4c470dcd5ec9c98779a62c8/python_bidi-0.6.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d6970b09f5a3102c0aa192f5258c585742ab4ebd94f637a635ad3448ccba567", size = 265032, upload-time = "2026-06-30T14:22:32.901Z" }, - { url = "https://files.pythonhosted.org/packages/cb/fc/87f3b820bbee3620bdd89047ee617db49700719a632d149e1c8a8c6ec59b/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:495a76c881d78ab87b57c5270679c9bc3c1de36d8c6596d5e3a5a1b5f9c57471", size = 292228, upload-time = "2026-06-30T14:21:30.411Z" }, - { url = "https://files.pythonhosted.org/packages/d2/2b/e48c592fcd01409bd09eb1a181c31702f7c048d06bca004b8840d101f69a/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9af2b5c26a3eb960699dff040535a86dc2c0f708087b2d63bcfd6452fe9d0664", size = 297708, upload-time = "2026-06-30T14:21:41.536Z" }, - { url = "https://files.pythonhosted.org/packages/77/68/9da530ac64b961f5dcdcaad03d90b35becb43dd7d244241809da11acca34/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f99162d6c6c9522c46eb213f1bc932829c2602131676c92f081cb865b8ef6784", size = 415289, upload-time = "2026-06-30T14:21:51.919Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8e/ce62bfec64769c28d537ef0481a36dd9ee42795b2975b41846f1aa82f7cc/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:caee7ee3662eab1411b44fef8571b87273cb5235061d7463ebd10e412ac07986", size = 318361, upload-time = "2026-06-30T14:22:02.282Z" }, - { url = "https://files.pythonhosted.org/packages/09/fb/57a496606a4faf051a3e851cc05a5bab2a1cd37b14fc738a707a5a51bba8/python_bidi-0.6.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3611d13b53d4c899c4f2a7cd8eb897064e8b5546c3c5d1037dd6209c82858a27", size = 296198, upload-time = "2026-06-30T14:22:22.914Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e9/31fbe166932d34860271ec5e0e0ecf0bc4166bddb889a260962a448d8617/python_bidi-0.6.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ca92a4e460f7e25e434a6d7982d94a4765ca242f527995da70abb5a32003b8a", size = 313188, upload-time = "2026-06-30T14:22:12.589Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ea/0c2ae4a316ea919698ede4201da1c07dc1c2f9a86bd3bce3f9b94b0ce7e6/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:539d99efe02f4981171ed57bbc085094ef780405ca14663550a56c6c3e265c34", size = 468437, upload-time = "2026-06-30T14:22:54.946Z" }, - { url = "https://files.pythonhosted.org/packages/17/6c/4795fb7f3ddca33a981158437ff6bd4c532d1011b9d887f47cff45d035e8/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9435acc52438c3c8f5142b9a17a622927618e80a32eed707343bc375cb51cebe", size = 573566, upload-time = "2026-06-30T14:23:07.133Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/8e707fb95cec1afed3fbffbf5eee93f2a73cad6f1445e17e32c6256cf397/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:239a00b2adb5f897d11d7b7a491d759fb9883e71a71cfd90c4147a733b4df4d3", size = 533591, upload-time = "2026-06-30T14:23:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4c/5e8d01ed3d2f8ca1116e1e21085a5ca97450b4035138988e5a82ea2db916/python_bidi-0.6.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:51915502898c45e9cb36636e974aca068fc5cdb9f06b794f49abea5b12f02016", size = 500360, upload-time = "2026-06-30T14:23:32.445Z" }, - { url = "https://files.pythonhosted.org/packages/b0/77/86bf9c4a95f363451e7c322ef76afbe56034da23a2b5532bd774751022ff/python_bidi-0.6.11-cp312-cp312-win32.whl", hash = "sha256:6c92d1cad16f9ec2f2a3ae439a0bc3a8e4189ec227987bed03d5b4056d5eb9c5", size = 157049, upload-time = "2026-06-30T14:23:54.895Z" }, - { url = "https://files.pythonhosted.org/packages/04/e3/8912d05e04575a60a0481cc222805331b74154940528a6f419fc5bbba744/python_bidi-0.6.11-cp312-cp312-win_amd64.whl", hash = "sha256:0608bddcc1c53dfa5293499de13ca9935b31aa46d1c722c404a88c703d1a4e47", size = 161243, upload-time = "2026-06-30T14:23:45.819Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/38f195e9d9a144747a2ca5ed6ec922df50534e6bacb2188a27154c9f9400/python_bidi-0.6.11-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b41cc6bc9ad78a12da5f987da15e931c771f18ceca58f2fe8ed50f253490a97", size = 266692, upload-time = "2026-06-30T14:22:44.497Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a0/4a29e0bfa45038edeeac9397c0c91aee674efbbaa962f0c32c17aaf1a2c4/python_bidi-0.6.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4d00757ef7bbbf14d8628f9bdb6b0e168d5e7b03fec20da3226624f11bffce89", size = 264510, upload-time = "2026-06-30T14:22:34.029Z" }, - { url = "https://files.pythonhosted.org/packages/6c/12/0c599f95cfd3433bb773dba3624fb0f00e74ee4d9f7c0c0455d935cf938d/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:302f4ca7dabfe447e707d40e98520551181036c15750bdd1e73292ad8b3d8e75", size = 291878, upload-time = "2026-06-30T14:21:31.721Z" }, - { url = "https://files.pythonhosted.org/packages/78/7d/ca9f710b5bc279decae719041795a0ab2fbc02f85a5231cf2e3f4195ed1f/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:721697187f4da67dafc26f63488f463fa35ff2de8668d959fda773cccdbef0eb", size = 297428, upload-time = "2026-06-30T14:21:42.745Z" }, - { url = "https://files.pythonhosted.org/packages/0c/0c/626a2fde3ba831ace3e092544617974dfb99d3a225cf7445987b1fd68b3e/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3da9e536546f7c62c0da595c8f71a096e0b9a80e94cfd0f329b7b200ef81e7d5", size = 414583, upload-time = "2026-06-30T14:21:53.006Z" }, - { url = "https://files.pythonhosted.org/packages/0d/93/23daba3a074f3b181fbeeef559735cd21ab55a8bad46934ed311696812b5/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d52ec8ccdc2fd5c61749d876a9d1eb0ea6543c1676722e1e3fb9d7800852131c", size = 318441, upload-time = "2026-06-30T14:22:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/66/60/569132a43fff4e52abbdd640b76b761a773c1c1b07f5bf3576be5049e8da/python_bidi-0.6.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74457b43db34f984252e915828b5d1a4042a771f44e853a5643506d01562eccb", size = 295716, upload-time = "2026-06-30T14:22:24.117Z" }, - { url = "https://files.pythonhosted.org/packages/7c/cd/a7ffa9ae8dd1903f3c17a9a2af6c531c92c3f7f7789f5c304ef77f94bd06/python_bidi-0.6.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:397f7f289eba6ce25d99dbea99f873d699bf9aa030074e7fb746d8f93c2fb6f9", size = 313112, upload-time = "2026-06-30T14:22:13.691Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e0/f252c15167d7b175e37514cf7d52cab3a244bfb0b64a5973c9ce6b33e191/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:946b7dbec4e64017680f1a66b3a8534659d889018ac83bd2abf958278e6f62b0", size = 468245, upload-time = "2026-06-30T14:22:56.534Z" }, - { url = "https://files.pythonhosted.org/packages/f9/06/7404eac40f2be2148cd0497251438e7690f553c5a09916d13d9803f8d32b/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:380b70d615647646dbe06f7d95dc30b8fdc9b596dbfa1cd3814feb9805c0c8f2", size = 573246, upload-time = "2026-06-30T14:23:08.653Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3e/49ca8310bdad3adedd6265da5c4877368ed528a0095348aa32edd8a0299e/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ffd728f1f7866ff7399906bbc17ef5cf010b90ce56a1e94937b28a1a4cf5a7d", size = 533495, upload-time = "2026-06-30T14:23:20.695Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/47cea7de2ebad67e30e5154d846f4d6a49347ca0dffcb76136be57a08226/python_bidi-0.6.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36f23f12d9b1c56ed8d82e11f56c6cba7bc3f614ee73374bc7772bb2270e0966", size = 499825, upload-time = "2026-06-30T14:23:33.846Z" }, - { url = "https://files.pythonhosted.org/packages/58/bb/94c89a185d9c5c6154a72583eff91448f48f5f776567af217326d839d8b3/python_bidi-0.6.11-cp313-cp313-win32.whl", hash = "sha256:79df1099a08e53edb678236d4d76d8de4e3901bafc84ce1788b71f9b96547325", size = 156754, upload-time = "2026-06-30T14:23:56.033Z" }, - { url = "https://files.pythonhosted.org/packages/85/0a/7ac8da3629ca8d93a419f5250c240fc13f7d34b7c04f279c8f9a474a2be9/python_bidi-0.6.11-cp313-cp313-win_amd64.whl", hash = "sha256:f563d20481f7d316adf605bb94d5b7182acecdbc4d431d60473e9b1d526d0210", size = 160888, upload-time = "2026-06-30T14:23:46.872Z" }, - { url = "https://files.pythonhosted.org/packages/1a/dc/8d088a648845e60ee8d3d758251909320d6a51774181d2a3e72e985af0b8/python_bidi-0.6.11-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5f3e1743b2d43377c4da5d687a03430a27f5769e158a9f75a50b05e7e82f4d21", size = 267877, upload-time = "2026-06-30T14:22:45.785Z" }, - { url = "https://files.pythonhosted.org/packages/ce/53/9c3e47a0579e5a3f168bb18ecfa197d494577ac19a1dd4557ecff99e2870/python_bidi-0.6.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6d9ef69c108b31f38e1f281a55fdedad7774bc1e952a45c8c14a18e891eee397", size = 265786, upload-time = "2026-06-30T14:22:35.254Z" }, - { url = "https://files.pythonhosted.org/packages/f6/51/3fc218678ac34a99065e45fdcc0209d2dd5e1f7766132fc4c58989a96d7d/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30d543b5baf9fca5ef5ec95647aa07c5e38fc7fa0f18be0c61d1d6c0a1032c6c", size = 293177, upload-time = "2026-06-30T14:21:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/f359cfa65a6716f23c069d17b6e2b87a8c673f499753c7f9712979096f6a/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14f2d400c75e07d1154299463a3d4d14aa5565b05088b2d9b314ccd9fd6dc3a", size = 297983, upload-time = "2026-06-30T14:21:43.731Z" }, - { url = "https://files.pythonhosted.org/packages/63/76/624bf0155d2b4fc2aa73e1276c22b66545a8d1f7280286c7e4dc443202db/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4f4a0cd24c09df748bfdc207b089c00e7af19f3151063f4cd74ac658290186b", size = 418424, upload-time = "2026-06-30T14:21:54.25Z" }, - { url = "https://files.pythonhosted.org/packages/28/cf/4a919e5be87a352ccb48b0ad2c01e2bca5d1f804d927e17df67904af62dd/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ff75d1befc335cbe85f834e81554a024f94d9b5d1dd75a5bd99af81a1cb783c", size = 319005, upload-time = "2026-06-30T14:22:04.514Z" }, - { url = "https://files.pythonhosted.org/packages/fa/25/2d9a3b0c4982ac60b8bc94b273eb7768cca18e92c5041807058fe81f4485/python_bidi-0.6.11-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:959335cd3814cb767fb832c5c71cbc838ccd9231a812ef2cb43092a216a91d5b", size = 296830, upload-time = "2026-06-30T14:22:25.187Z" }, - { url = "https://files.pythonhosted.org/packages/61/d9/1798bf13b0e8167fee6a1bd0c2ec374c8d49a6de34a205a1a67bb8d45fb2/python_bidi-0.6.11-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20ab47a4098577fc9a82816c330c89ff597c31ba69c98bc6a1b6a5737b03de05", size = 313852, upload-time = "2026-06-30T14:22:14.793Z" }, - { url = "https://files.pythonhosted.org/packages/25/aa/56a51fed9718e751a93ea3a4b894217a04cfaef0704159b400dee5fa5b4a/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60d9bf6c60c022657637f64897e63dc3f5b1c07cb7f0e1ead6150aff5150c5ab", size = 469382, upload-time = "2026-06-30T14:22:57.838Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8b/82979c858cba237355aee8a2a35c317ba412c4bce494e8a51ccb1c9e5321/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd564b8c583eba2d230d02d0467fd840045f08856b6524555cfff7f32af63c72", size = 573783, upload-time = "2026-06-30T14:23:10.012Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/a88d71d784144283f4e53a9849c4788c8a5fcce56a1690e073c014fa34fe/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fc3b0a6e2460f68de9ab98f71e3098bb21bb984563417ad104dec7ab08ebcadc", size = 534229, upload-time = "2026-06-30T14:23:22.108Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f1/c50692fd2cfccb563fecac7c53af00c141bd9bed09fb9cd626e0d765c53d/python_bidi-0.6.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3bdb64ee0a74951465cd4a761e1029e73806ff43b2fe5be98643e52da5cabb66", size = 501275, upload-time = "2026-06-30T14:23:35.379Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0f/4a83866615b86572d0854b9623e7c44de51a7d3e21b16880cfeb0fdc1b49/python_bidi-0.6.11-cp314-cp314-win32.whl", hash = "sha256:f237ebb570fd8bbe479b6967374d82b7f0b26f9452c276bdd5f793d83e7062bd", size = 157398, upload-time = "2026-06-30T14:23:57.202Z" }, - { url = "https://files.pythonhosted.org/packages/90/78/bf20f1ab2cafaf744df006691e0f7d292f95f7c01fbaced92f5970ab3f8a/python_bidi-0.6.11-cp314-cp314-win_amd64.whl", hash = "sha256:8fbb6d222b50324fb9d49b6ff0f8566fa97b907a68c00e6622fcf34463104f4a", size = 161347, upload-time = "2026-06-30T14:23:47.921Z" }, - { url = "https://files.pythonhosted.org/packages/4d/da/cabfc8c055b53d845de46a78b641996a8ca2e4b9f7c9667fc8a54e7c9030/python_bidi-0.6.11-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:43f3e81bdd36f49171b7de6cf471086df503c29555f6f7f035ebe8f8ec1da779", size = 267716, upload-time = "2026-06-30T14:22:46.973Z" }, - { url = "https://files.pythonhosted.org/packages/b7/0e/2839f8671a2201c5e1776e04ac48170e9b8a6c989147fe1b656d19603c7a/python_bidi-0.6.11-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7291e13496cb74fc1b71f7f1e3628586afefa531102bb4fa7af9c2d543efc3c", size = 265337, upload-time = "2026-06-30T14:22:36.426Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b8/561bfe22ac7ad3de4017542c4c6c192d0fa83a416ab2e0a66728c898e937/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ccf1fe9ecb3b02a1a11b103cd2557e2653d82c141b6e2dccec8177e6af5c4bb", size = 292362, upload-time = "2026-06-30T14:21:34.19Z" }, - { url = "https://files.pythonhosted.org/packages/45/ae/2b1159ac11e4516f566d83572481a0ca453abe132a43db4c048931676bfd/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f400a30573774a1e90c0d50d43c35d9c004afcf53de801bbfd259f84ea80f31c", size = 297068, upload-time = "2026-06-30T14:21:44.769Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d1/cf4c90a99d54ac01aeb186335dbf5a9cb89b3afbd5adc0b77b5c6f826134/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788c11c84b520ea973992cc95751d56b783ff5857df504c1347d99cacd2fcfe7", size = 416236, upload-time = "2026-06-30T14:21:55.374Z" }, - { url = "https://files.pythonhosted.org/packages/ed/89/526f2b7be7c2e0d72dee52955538f816b7855e20d4a8e092516c13cbef78/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:879c3fba3e7511c7d01020449d970ca7d6a593f20cfc47c014d3d229a38930b2", size = 318604, upload-time = "2026-06-30T14:22:05.574Z" }, - { url = "https://files.pythonhosted.org/packages/67/a7/779464d0a6a96566f77bf8b76ec3d83cefadd8210ac758f4492942192357/python_bidi-0.6.11-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dc112e2239f69913273cbb0c050ca816b145b32037d4266c430159c5ddcdab2", size = 296857, upload-time = "2026-06-30T14:22:26.36Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bf/cba41041e3a4369be495011bd773aaedefbfef59d6c21e121e61f8ac1e2b/python_bidi-0.6.11-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19bae96ff1ee76b3a7dc962598b69426538e3021460cf85a4017437548ab6947", size = 313478, upload-time = "2026-06-30T14:22:15.968Z" }, - { url = "https://files.pythonhosted.org/packages/be/88/a4cd8dea27cab1eee8ee33be1ee2a948a7a53d37beb071811d2a0aa328d1/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:565d819fddb2bbc58c42ca5c97d73da7567f181115011e8f04c76b9d08378dcd", size = 468573, upload-time = "2026-06-30T14:22:59.11Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f3/9d8954d038e876386bb36fce4f0df61c6cf5b0664e69ab4c1914420d397e/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e6c474618a7b6a50c10007f8a9edb50def6d98d297a07a34dc2fb82c344f2b8b", size = 572916, upload-time = "2026-06-30T14:23:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/a5f8763031912748e17e0f0e06e7056b377754c3a084432f6c7f32847acd/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:73c38c604bfc01647c69ce38e5cf8b4206e2ede91ca3eb8e5d79b7409f17e0b3", size = 533968, upload-time = "2026-06-30T14:23:24.072Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5c/823d93e8ea9e05e77eb76eace84ea9879fe876890da616a41077b8c7ffd5/python_bidi-0.6.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c26cd9d81f820159026b1c99905ca12bf13892e3f6a9303359fef42fe6f39e50", size = 500671, upload-time = "2026-06-30T14:23:36.907Z" }, - { url = "https://files.pythonhosted.org/packages/a1/00/9d16556d2e0bb4a4b2132fa37fc6235e333c6bf5bccd1375ce0a15ea1db1/python_bidi-0.6.11-cp314-cp314t-win32.whl", hash = "sha256:dfbb9ba8343a60daf4ced67c11d551dafe9a3c94892c326e4c216fa2e6eca802", size = 157355, upload-time = "2026-06-30T14:23:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b6/13ea093c161da232a6eb534d420fe575ad802b0c8184860fe4f3881fbc08/python_bidi-0.6.11-cp314-cp314t-win_amd64.whl", hash = "sha256:6623683fe39b9fbf508e3069f17e8e9cab26143f9d9f89c8a8f45424c052df4f", size = 161439, upload-time = "2026-06-30T14:23:49.011Z" }, - { url = "https://files.pythonhosted.org/packages/29/27/b4878ebed0c75833629aefe0b9cbff588df86efe6068d4c100ee8d0df27b/python_bidi-0.6.11-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8eb09af209cd660fa9689f6ce9e61e73c8afa4829ae61801deea7f6e32263800", size = 271411, upload-time = "2026-06-30T14:22:50.789Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d5/080a6acda54809d12736eaf58cd71b930eba431eab78b2920727204f8a94/python_bidi-0.6.11-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7a8429d0d65232e314b4f494825c1205abcc3039f42bf7da80424a15b731709", size = 271915, upload-time = "2026-06-30T14:22:39.907Z" }, - { url = "https://files.pythonhosted.org/packages/2b/33/a076031a95627bef4e051d94ac90ff44dac687e988da4417a387e226555d/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6b3c853f99172ef22e5a16c8114cf243e351c8e70f72a894164088c2c99d9cb", size = 296494, upload-time = "2026-06-30T14:21:38.138Z" }, - { url = "https://files.pythonhosted.org/packages/3d/dd/dcb312034d421f99b00d2d2b37b1a4b6ce919a3f0c70ed3aa5570b4d1bef/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:69d1f4ee17644e8aeac93a7238ee2f28d79b0180815441eb511b77e6585aa971", size = 302105, upload-time = "2026-06-30T14:21:48.328Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e2/25192b48e4bedf7491daa7aa50e196d06b46ffa495b039d492845173bffb/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80cfe97e2d65981be877ae5bd2338c6919a7cc1171fc308c8b8c7c306ba6ffd8", size = 419597, upload-time = "2026-06-30T14:21:58.598Z" }, - { url = "https://files.pythonhosted.org/packages/45/c0/1fb868cf41aab7e22cf01e8a4717c8447a43eadda8ccf367f3660c9005d8/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83c780f7e4c3dd3f020db75dc425567981e65c6d5571b3c0372203d0df92c834", size = 322465, upload-time = "2026-06-30T14:22:08.965Z" }, - { url = "https://files.pythonhosted.org/packages/27/41/a7903ff2829d16eae2b11dff1cffeb9739373b8fe12c27ab04dda7c5c45d/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac819cac1abb15486c48af3399a5c726e89f0977a3aff205ac162533186e756", size = 300467, upload-time = "2026-06-30T14:22:29.729Z" }, - { url = "https://files.pythonhosted.org/packages/29/eb/16f4fb6acaf5d381da8faf26a306a255e54417450674d4c004a0ba0545ae/python_bidi-0.6.11-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:63d9dcc714d549a5118ebc93b7a7c903a0c1feec8e57f5fcacf98d984b76325b", size = 318422, upload-time = "2026-06-30T14:22:19.416Z" }, - { url = "https://files.pythonhosted.org/packages/86/04/ef0ba9e878bb5169e32bbd96e3e39ab60e947ae1757b7dd645eb4fc2e7a4/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d73a821873c52635321196cfb8d3a231d7917ca284cf2bcd9422f6deb19db7ca", size = 473278, upload-time = "2026-06-30T14:23:03.142Z" }, - { url = "https://files.pythonhosted.org/packages/be/93/b3aa2631c4801ddd5c22f93e430ed1d570bcb634c4a217e864195cbe7574/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:bde89739a979d9eb3ac48c4882c8a42dd528a7708b0faa99b90b551588bd5f8d", size = 577704, upload-time = "2026-06-30T14:23:15.336Z" }, - { url = "https://files.pythonhosted.org/packages/87/b0/f5b755e1e4807bb403ccad09b1ca4faca52e260f384595e9362a68e378a6/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7738cfc7ee9fcdff3fef76b40007982ce7704010a51df307c4a51d378f5ff1ba", size = 538805, upload-time = "2026-06-30T14:23:28.158Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f0/f6b9d17e3426e7b54c18d05d917982647a976233ed9348a2ef40f1a17f85/python_bidi-0.6.11-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:cf4e88a6fec81b7155a487cbbea7753a3d9a76dc4d391b4f8958b37227ef2c12", size = 505204, upload-time = "2026-06-30T14:23:41.222Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2728,128 +2480,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] -[[package]] -name = "scikit-image" -version = "0.25.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "imageio" }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/cb/016c63f16065c2d333c8ed0337e18a5cdf9bc32d402e4f26b0db362eb0e2/scikit_image-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3278f586793176599df6a4cf48cb6beadae35c31e58dc01a98023af3dc31c78", size = 13988922, upload-time = "2025-02-18T18:04:11.069Z" }, - { url = "https://files.pythonhosted.org/packages/30/ca/ff4731289cbed63c94a0c9a5b672976603118de78ed21910d9060c82e859/scikit_image-0.25.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c311069899ce757d7dbf1d03e32acb38bb06153236ae77fcd820fd62044c063", size = 13192698, upload-time = "2025-02-18T18:04:15.362Z" }, - { url = "https://files.pythonhosted.org/packages/39/6d/a2aadb1be6d8e149199bb9b540ccde9e9622826e1ab42fe01de4c35ab918/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be455aa7039a6afa54e84f9e38293733a2622b8c2fb3362b822d459cc5605e99", size = 14153634, upload-time = "2025-02-18T18:04:18.496Z" }, - { url = "https://files.pythonhosted.org/packages/96/08/916e7d9ee4721031b2f625db54b11d8379bd51707afaa3e5a29aecf10bc4/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c464b90e978d137330be433df4e76d92ad3c5f46a22f159520ce0fdbea8a09", size = 14767545, upload-time = "2025-02-18T18:04:22.556Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ee/c53a009e3997dda9d285402f19226fbd17b5b3cb215da391c4ed084a1424/scikit_image-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:60516257c5a2d2f74387c502aa2f15a0ef3498fbeaa749f730ab18f0a40fd054", size = 12812908, upload-time = "2025-02-18T18:04:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" }, - { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" }, - { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" }, - { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" }, -] - -[[package]] -name = "scikit-image" -version = "0.26.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "imageio" }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "tifffile", version = "2026.8.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/16/8a407688b607f86f81f8c649bf0d68a2a6d67375f18c2d660aba20f5b648/scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0", size = 12355510, upload-time = "2025-12-20T17:10:31.628Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f9/7efc088ececb6f6868fd4475e16cfafc11f242ce9ab5fc3557d78b5da0d4/scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd", size = 12056334, upload-time = "2025-12-20T17:10:34.559Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/bc7fb91fb5ff65ef42346c8b7ee8b09b04eabf89235ab7dbfdfd96cbd1ea/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953", size = 13297768, upload-time = "2025-12-20T17:10:37.733Z" }, - { url = "https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af", size = 13711217, upload-time = "2025-12-20T17:10:40.935Z" }, - { url = "https://files.pythonhosted.org/packages/d4/59/9637ee12c23726266b91296791465218973ce1ad3e4c56fc81e4d8e7d6e1/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d", size = 14337782, upload-time = "2025-12-20T17:10:43.452Z" }, - { url = "https://files.pythonhosted.org/packages/e7/5c/a3e1e0860f9294663f540c117e4bf83d55e5b47c281d475cc06227e88411/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d", size = 14805997, upload-time = "2025-12-20T17:10:45.93Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c6/2eeacf173da041a9e388975f54e5c49df750757fcfc3ee293cdbbae1ea0a/scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7", size = 11878486, upload-time = "2025-12-20T17:10:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a4/a852c4949b9058d585e762a66bf7e9a2cd3be4795cd940413dfbfbb0ce79/scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581", size = 11346518, upload-time = "2025-12-20T17:10:50.575Z" }, - { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, - { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, - { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, - { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, - { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, - { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, - { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, - { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, - { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, - { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, - { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, - { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, - { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, - { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, - { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, - { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, - { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, - { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, - { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, - { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, - { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, - { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, - { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, - { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, - { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, - { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, - { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, - { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, - { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, - { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, - { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, -] - [[package]] name = "scikit-learn" version = "1.7.2" @@ -3178,75 +2808,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, - { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, - { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, - { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, - { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, - { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, - { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, - { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, - { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, - { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, - { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, - { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, - { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, - { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, - { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, - { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, - { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, - { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" @@ -3304,58 +2865,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] -[[package]] -name = "tifffile" -version = "2025.5.10" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/06/bd0a6097da704a7a7c34a94cfd771c3ea3c2f405dd214e790d22c93f6be1/tifffile-2025.5.10-py3-none-any.whl", hash = "sha256:e37147123c0542d67bc37ba5cdd67e12ea6fbe6e86c52bee037a9eb6a064e5ad", size = 226533, upload-time = "2025-05-10T19:22:27.279Z" }, -] - -[[package]] -name = "tifffile" -version = "2026.3.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, -] - -[[package]] -name = "tifffile" -version = "2026.8.23" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/07/90078f49e60718d414d5440dc498301f11ff049458e65cf8d27c62a5c9d1/tifffile-2026.8.23.tar.gz", hash = "sha256:bd3c816f166f85c93329a54a0c9a1eccc9968a6a78f91d63b65d7b17675915f2", size = 446179, upload-time = "2026-08-23T18:45:05.976Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/38/05347ae8b1b268c068b3ca38822558038f38c6494604837d7ca3cbcceb9c/tifffile-2026.8.23-py3-none-any.whl", hash = "sha256:a03045afce67d97cb4ef56a1cd47eae6ab8115c7e84cfedeb5cadbfbb6d14fbe", size = 273795, upload-time = "2026-08-23T18:45:04.466Z" }, -] - [[package]] name = "tinycss2" version = "1.5.1" @@ -3671,7 +3180,6 @@ dependencies = [ [package.optional-dependencies] all = [ - { name = "easyocr" }, { name = "graphviz" }, { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3696,7 +3204,6 @@ graphviz = [ { name = "graphviz" }, ] samvg = [ - { name = "easyocr" }, { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3724,7 +3231,6 @@ vision = [ requires-dist = [ { name = "anthropic", specifier = ">=0.47.0" }, { name = "cairosvg", specifier = ">=2.7.0" }, - { name = "easyocr", marker = "extra == 'samvg'", specifier = ">=1.7.2" }, { name = "google-genai", specifier = ">=1.68.0" }, { name = "graphviz", marker = "extra == 'graphviz'", specifier = ">=0.21" }, { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.10.8" }, @@ -3746,8 +3252,8 @@ requires-dist = [ { name = "torchvision", marker = "extra == 'samvg'", specifier = ">=0.28.0", index = "https://download.pytorch.org/whl/cu126" }, { name = "torchvision", marker = "extra == 'vision'", specifier = ">=0.28.0", index = "https://download.pytorch.org/whl/cu126" }, { name = "tqdm", specifier = ">=4.67.3" }, - { name = "transformers", marker = "extra == 'samvg'", specifier = ">=4.40.0" }, - { name = "transformers", marker = "extra == 'vision'", specifier = ">=4.40.0" }, + { name = "transformers", marker = "extra == 'samvg'", specifier = ">=4.49.0" }, + { name = "transformers", marker = "extra == 'vision'", specifier = ">=4.49.0" }, { name = "typst", marker = "extra == 'typst'", specifier = ">=0.11.0" }, { name = "vectrify", extras = ["graphviz", "samvg", "typst", "vision"], marker = "extra == 'all'" }, ] From 8045a7c795531adb35064af883553f0369df9b3a Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 12:29:00 +0200 Subject: [PATCH 05/57] feat: verify OCR text against seed pixels --- src/vectrify/refine/samvg.py | 59 +++++++++++++++++++++++++++++------ src/vectrify/vector/runner.py | 9 +++++- tests/refine/test_samvg.py | 27 ++++++++++++++++ 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index f6950281..bf0c14d2 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -17,9 +17,9 @@ import re import xml.etree.ElementTree as ET from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast -from xml.sax.saxutils import escape import numpy as np from PIL import Image @@ -814,6 +814,7 @@ def generate_svg( segments: int = 16, fill_holes: bool = True, ocr: bool = True, + rasterize: Callable[[str, int, int], bytes] | None = None, ) -> str: """Generate SAMVG's traced, pre-optimisation SVG from a target image.""" image = image.convert("RGB") @@ -841,17 +842,15 @@ def generate_svg( if attributes: markup = " ".join(f'{key}="{value}"' for key, value in attributes.items()) paths.append(f"") - text_layers = detect_text(image) if ocr and masks is None else [] - text = [] - for layer in text_layers: - attributes = _text_svg_attributes(layer) - markup = " ".join(f'{key}="{value}"' for key, value in attributes.items()) - text.append(f"{escape(layer.text)}") width, height = image.size - return ( + svg = ( f'' + "".join(paths) + "".join(text) + "" + f'viewBox="0 0 {width} {height}">' + "".join(paths) + "" ) + text_layers = detect_text(image) if ocr and masks is None else [] + if text_layers and rasterize is not None: + return _accept_text_layers(svg, image, text_layers, rasterize) + return _append_text_layers(svg, text_layers) def residual_prompt_points( @@ -905,6 +904,19 @@ def _append_layers(svg: str, layers: list[MaskLayer], segments: int) -> str: return ET.tostring(root, encoding="unicode") +def _append_text_layers(svg: str, layers: list[TextLayer]) -> str: + """Append editable OCR text without changing the pre-existing drawing.""" + if not layers: + return svg + root = ET.fromstring(svg) + for layer in layers: + element = ET.SubElement( + root, "{http://www.w3.org/2000/svg}text", _text_svg_attributes(layer) + ) + element.text = layer.text + return ET.tostring(root, encoding="unicode") + + def _render_svg(svg: str, image: Image.Image, rasterize) -> Image.Image: return Image.open(io.BytesIO(rasterize(svg, image.width, image.height))).convert( "RGB" @@ -917,6 +929,35 @@ def _mse(image: Image.Image, rendered: Image.Image) -> float: return float(((target - candidate) ** 2).mean()) +def _accept_text_layers( + svg: str, + image: Image.Image, + layers: list[TextLayer], + rasterize: Callable[[str, int, int], bytes], +) -> str: + """Greedily retain only OCR text that improves the Cairo pixel loss. + + A VLM's asserted confidence is not evidence that a word is present. The + same rasterisation used to score the seed is the final verifier, including + font mismatch, positioning, and any existing SAM paths beneath the text. + """ + accepted = svg + error = _mse(image, _render_svg(accepted, image, rasterize)) + retained = 0 + for layer in layers: + candidate = _append_text_layers(accepted, [layer]) + candidate_error = _mse(image, _render_svg(candidate, image, rasterize)) + if candidate_error < error: + accepted, error = candidate, candidate_error + retained += 1 + log.info( + "SAMVG OCR: retained %d/%d text layer(s) after pixel verification.", + retained, + len(layers), + ) + return accepted + + def _accepted_fit( svg: str, image: Image.Image, *, rasterize, steps: int ) -> tuple[str, Image.Image]: diff --git a/src/vectrify/vector/runner.py b/src/vectrify/vector/runner.py index fbee3425..a6559758 100644 --- a/src/vectrify/vector/runner.py +++ b/src/vectrify/vector/runner.py @@ -478,7 +478,14 @@ def run_vector_search( log.info("SAMVG-inspired seed skipped: it is available for SVG only.") else: try: - content = format_plugin.extract_from_llm(generate_svg(original_img)) + content = format_plugin.extract_from_llm( + generate_svg( + original_img, + rasterize=lambda svg, width, height: format_plugin.rasterize( + svg, out_w=width, out_h=height + ), + ) + ) valid, error = format_plugin.validate(content) if not valid: raise ValueError(error or "generated SVG failed validation") diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 33d47ef4..a923cd72 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -109,6 +109,33 @@ def test_generate_svg_writes_detected_words_as_editable_text(monkeypatch): assert text.get("font-size") == "8.00" +def test_generate_svg_keeps_only_pixel_improving_text(monkeypatch): + target = Image.new("RGB", (32, 16), "black") + monkeypatch.setattr(samvg, "retrieve_layers", lambda *_args, **_kwargs: []) + monkeypatch.setattr( + samvg, + "detect_text", + lambda _image: [ + TextLayer("keep", 2, 3, 18, 8, (20, 30, 40)), + TextLayer("discard", 2, 3, 18, 8, (20, 30, 40)), + ], + ) + monkeypatch.setattr( + samvg, + "_render_svg", + lambda svg, _image, _rasterize: ( + target if "keep" in svg else Image.new("RGB", (32, 16), "white") + ), + ) + + root = ET.fromstring(generate_svg(target, rasterize=lambda *_args: b"")) + labels = [ + element.text for element in root.findall("{http://www.w3.org/2000/svg}text") + ] + + assert labels == ["keep"] + + def test_automatic_masks_uses_source_sized_first_layer_crops(monkeypatch): calls = [] From cddc252d380d4d24363e31a2e086a2ba2fe95cf2 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 12:32:31 +0200 Subject: [PATCH 06/57] feat: tolerate small OCR text mismatches --- src/vectrify/refine/samvg.py | 17 +++++++++++++++-- tests/refine/test_samvg.py | 26 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index bf0c14d2..e35cd73b 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -42,6 +42,10 @@ SAMVG_OCR_MODEL = os.environ.get( "VECTRIFY_SAMVG_OCR_MODEL", "Qwen/Qwen2.5-VL-3B-Instruct" ) +# OCR text is often a few pixels off because its original font is unknown. +# Permit that small mismatch (per affected channel), but never a large visual +# regression just because the VLM claimed confidence. +OCR_TEXT_RMSE_TOLERANCE = 0.02 @dataclass(frozen=True) @@ -929,13 +933,22 @@ def _mse(image: Image.Image, rendered: Image.Image) -> float: return float(((target - candidate) ** 2).mean()) +def _text_error_tolerance(layer: TextLayer, image: Image.Image) -> float: + """Return the whole-image MSE budget for this one text bounding box.""" + padding = 2 + width = min(image.width, max(1, math.ceil(layer.width) + padding * 2)) + height = min(image.height, max(1, math.ceil(layer.height) + padding * 2)) + affected_fraction = (width * height) / (image.width * image.height) + return affected_fraction * (255 * OCR_TEXT_RMSE_TOLERANCE) ** 2 + + def _accept_text_layers( svg: str, image: Image.Image, layers: list[TextLayer], rasterize: Callable[[str, int, int], bytes], ) -> str: - """Greedily retain only OCR text that improves the Cairo pixel loss. + """Retain OCR text that improves, or only negligibly worsens, pixel loss. A VLM's asserted confidence is not evidence that a word is present. The same rasterisation used to score the seed is the final verifier, including @@ -947,7 +960,7 @@ def _accept_text_layers( for layer in layers: candidate = _append_text_layers(accepted, [layer]) candidate_error = _mse(image, _render_svg(candidate, image, rasterize)) - if candidate_error < error: + if candidate_error <= error + _text_error_tolerance(layer, image): accepted, error = candidate, candidate_error retained += 1 log.info( diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index a923cd72..ddb06c66 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -124,7 +124,11 @@ def test_generate_svg_keeps_only_pixel_improving_text(monkeypatch): samvg, "_render_svg", lambda svg, _image, _rasterize: ( - target if "keep" in svg else Image.new("RGB", (32, 16), "white") + Image.new("RGB", (32, 16), "white") + if "discard" in svg + else target + if "keep" in svg + else Image.new("RGB", (32, 16), "white") ), ) @@ -136,6 +140,26 @@ def test_generate_svg_keeps_only_pixel_improving_text(monkeypatch): assert labels == ["keep"] +def test_pixel_gate_allows_a_small_font_or_placement_mismatch(monkeypatch): + target = Image.new("RGB", (32, 16), "black") + monkeypatch.setattr( + samvg, + "_render_svg", + lambda svg, _image, _rasterize: ( + Image.new("RGB", (32, 16), (2, 2, 2)) if "near" in svg else target + ), + ) + + result = samvg._accept_text_layers( + '', + target, + [TextLayer("near", 2, 3, 18, 8, (20, 30, 40))], + lambda *_args: b"", + ) + + assert "near" in result + + def test_automatic_masks_uses_source_sized_first_layer_crops(monkeypatch): calls = [] From 503020e341e84027d78587d56d4fd93f0339e008 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 12:41:00 +0200 Subject: [PATCH 07/57] refactor: remove scientific stack from SAMVG --- pyproject.toml | 2 - src/vectrify/refine/samvg.py | 165 ++++++++++++++++++++++++++++++----- tests/refine/test_samvg.py | 31 ++++++- uv.lock | 7 -- 4 files changed, 175 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 57b8a0da..2eec9b63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,8 +61,6 @@ vision = [ # extra is deliberately sufficient for the SAMVG seed even on machines that # use the portable Torch renderer fallback. samvg = [ - "scipy>=1.11.0", - "scikit-learn>=1.3.0", "torch>=2.0.0", "torchvision>=0.28.0", "transformers>=4.49.0", diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index e35cd73b..c8600dba 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -16,7 +16,7 @@ import os import re import xml.etree.ElementTree as ET -from collections import defaultdict +from collections import defaultdict, deque from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast @@ -241,6 +241,137 @@ def _is_crop_edge_mask( return bool(np.any(at_crop_edge & ~at_image_edge)) +def _label(mask: np.ndarray) -> tuple[np.ndarray, int]: + """Label 4-connected foreground components like scipy.ndimage.label.""" + foreground = np.asarray(mask, dtype=bool) + labels = np.zeros(foreground.shape, dtype=np.int32) + height, width = foreground.shape + count = 0 + for y, x in zip(*np.nonzero(foreground), strict=True): + if labels[y, x]: + continue + count += 1 + labels[y, x] = count + pending = deque([(int(y), int(x))]) + while pending: + row, column = pending.popleft() + for next_y, next_x in ( + (row - 1, column), + (row + 1, column), + (row, column - 1), + (row, column + 1), + ): + if ( + 0 <= next_y < height + and 0 <= next_x < width + and foreground[next_y, next_x] + and not labels[next_y, next_x] + ): + labels[next_y, next_x] = count + pending.append((next_y, next_x)) + return labels, count + + +def _edt_1d(values: np.ndarray) -> np.ndarray: + """Squared lower envelope for the linear-time Euclidean distance transform.""" + size = len(values) + infinity = np.inf + sites = np.flatnonzero(np.isfinite(values)) + if not len(sites): + return np.full(size, infinity, dtype=np.float64) + vertices = np.empty(len(sites), dtype=np.int32) + intersections = np.empty(len(sites) + 1, dtype=np.float64) + count = 0 + vertices[0] = sites[0] + intersections[0], intersections[1] = -infinity, infinity + for site in sites[1:]: + intersection = ( + (values[site] + site * site) + - (values[vertices[count]] + vertices[count] * vertices[count]) + ) / (2 * (site - vertices[count])) + while intersection <= intersections[count]: + count -= 1 + intersection = ( + (values[site] + site * site) + - (values[vertices[count]] + vertices[count] * vertices[count]) + ) / (2 * (site - vertices[count])) + count += 1 + vertices[count] = site + intersections[count], intersections[count + 1] = intersection, infinity + output = np.empty(size, dtype=np.float64) + index = 0 + for position in range(size): + while intersections[index + 1] < position: + index += 1 + site = vertices[index] + output[position] = (position - site) ** 2 + values[site] + return output + + +def _distance_transform_edt(mask: np.ndarray) -> np.ndarray: + """Exact CPU Euclidean distance to the nearest false pixel, without SciPy.""" + foreground = np.asarray(mask, dtype=bool) + height, width = foreground.shape + squared = np.where(foreground, np.inf, 0.0) + if not np.isfinite(squared).any(): + yy, xx = np.indices((height, width), dtype=np.float64) + return np.hypot(yy + 1, xx) + columns = np.empty_like(squared) + for column in range(width): + columns[:, column] = _edt_1d(squared[:, column]) + output = np.empty_like(squared) + for row in range(height): + output[row] = _edt_1d(columns[row]) + return np.sqrt(output) + + +def _binary_dilation(mask: np.ndarray, iterations: int) -> np.ndarray: + """Apply scipy's default 4-connected binary dilation with Torch kernels.""" + if iterations <= 0: + return np.asarray(mask, dtype=bool) + import torch + import torch.nn.functional as functional + + source = torch.as_tensor(mask, dtype=torch.float32)[None, None] + cross = source.new_tensor([[[[0, 1, 0], [1, 1, 1], [0, 1, 0]]]]) + for _ in range(iterations): + source = (functional.conv2d(source, cross, padding=1) > 0).to(source.dtype) + return source[0, 0].bool().numpy() + + +def _mean_shift_centres(points: np.ndarray, bandwidth: float) -> np.ndarray: + """Deterministic bin-seeded mean shift matching SAMVG's prompt clustering.""" + bins = np.unique(np.rint(points / bandwidth).astype(np.int32), axis=0) + seeds = bins.astype(np.float64) * bandwidth + centres: dict[tuple[float, float], int] = {} + for seed in seeds: + centre = seed + members = np.empty(0, dtype=np.int64) + for _ in range(300): + delta = points - centre + members = np.flatnonzero((delta * delta).sum(axis=1) <= bandwidth**2) + if not len(members): + break + updated = points[members].mean(axis=0) + if np.linalg.norm(updated - centre) < bandwidth * 1e-3: + centre = updated + break + centre = updated + if len(members): + centres[tuple(centre)] = len(members) + # This intentionally follows sklearn's intensity-then-coordinate ordering + # and radius duplicate suppression, preserving the old prompt priority. + ordered = sorted(centres.items(), key=lambda item: (item[1], item[0]), reverse=True) + candidates = np.asarray([centre for centre, _count in ordered], dtype=np.float64) + unique = np.ones(len(candidates), dtype=bool) + for index, centre in enumerate(candidates): + if unique[index]: + neighbours = np.linalg.norm(candidates - centre, axis=1) <= bandwidth + unique[neighbours] = False + unique[index] = True + return candidates[unique] + + def automatic_masks(image: Image.Image) -> list[np.ndarray]: """Retrieve SAM AMG masks with the thesis's 32-point grid and crops.""" try: @@ -301,9 +432,7 @@ def _components( holes before tracing matches AMG's small-region cleanup and prevents a noisy mask from becoming hundreds of even-odd SVG contours. """ - from scipy.ndimage import label - - labels, count = label(mask) + labels, count = _label(mask) components = [] for index in range(1, count + 1): component = labels == index @@ -314,7 +443,7 @@ def _components( # than turning meaningful cutouts such as an eye into a solid # region. The same area cutoff as tiny components keeps those # two decisions consistent. - background, hole_count = label(~component) + background, hole_count = _label(~component) for hole in range(1, hole_count + 1): points = background == hole if ( @@ -452,18 +581,15 @@ def coverage_prompt_points( max_points: int = 16, ) -> list[tuple[int, int]]: """Find mean-shift centres of large circles untouched by retained masks.""" - from scipy.ndimage import distance_transform_edt - from sklearn.cluster import MeanShift - _canvas, coverage = _render_layers(shape, layers) radius = max(2, round(min(shape) * radius_fraction)) - distance = np.asarray(distance_transform_edt(~coverage)) + distance = _distance_transform_edt(~coverage) ys, xs = np.nonzero(distance >= radius) if len(xs) == 0: return [] stride = max(1, len(xs) // 2_048) points = np.column_stack((xs[::stride], ys[::stride])) - centres = MeanShift(bandwidth=radius, bin_seeding=True).fit(points).cluster_centers_ + centres = _mean_shift_centres(points, radius) ranked = sorted( ((float(distance[round(y), round(x)]), round(x), round(y)) for x, y in centres), reverse=True, @@ -716,9 +842,7 @@ def mask_path( ) -> str | None: """Fit every mask contour as a fixed-count cubic Bezier SVG path.""" if overlap_pixels: - from scipy.ndimage import binary_dilation - - mask = binary_dilation(mask, iterations=overlap_pixels) + mask = _binary_dilation(mask, overlap_pixels) parts = [piece for loop in _loops(mask) if (piece := _cubic_loop(loop, segments))] return " ".join(parts) or None @@ -734,9 +858,7 @@ def mask_stroke( original filled-path treatment. """ if overlap_pixels: - from scipy.ndimage import binary_dilation - - mask = binary_dilation(mask, iterations=overlap_pixels) + mask = _binary_dilation(mask, overlap_pixels) ys, xs = np.nonzero(mask) if len(xs) < 8: return None @@ -866,8 +988,8 @@ def residual_prompt_points( max_points: int = 16, ) -> list[tuple[int, int]]: """Locate SAMVG's convolved, thresholded residual components.""" - from scipy.ndimage import label - from scipy.signal import fftconvolve + import torch + import torch.nn.functional as functional target_pixels = np.asarray(target.convert("RGB"), dtype=np.float32) / 255.0 rendered_pixels = np.asarray(rendered.convert("RGB"), dtype=np.float32) / 255.0 @@ -881,8 +1003,11 @@ def residual_prompt_points( # Reflected padding preserves the prior symmetric-boundary definition; # FFT convolution keeps the full-resolution recovery pass practical. padded = np.pad(difference, radius, mode="symmetric") - smoothed = fftconvolve(padded, kernel / kernel.sum(), mode="valid") - labels, count = label(smoothed >= threshold) + smoothed = functional.conv2d( + torch.from_numpy(padded)[None, None], + torch.from_numpy((kernel / kernel.sum())[None, None]), + )[0, 0].numpy() + labels, count = _label(smoothed >= threshold) points: list[tuple[float, int, int]] = [] for index in range(1, count + 1): ys, xs = np.nonzero(labels == index) diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index ddb06c66..18a9c236 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -10,9 +10,12 @@ from vectrify.refine.samvg import ( MaskLayer, TextLayer, + _binary_dilation, _components, + _distance_transform_edt, _fit_cubic, _is_crop_edge_mask, + _label, _text_svg_attributes, automatic_masks, coverage_prompt_points, @@ -251,6 +254,21 @@ def test_components_fill_only_tiny_enclosed_holes(): assert components[0].all() +def test_internal_morphology_matches_scipy_default_connectivity(): + mask = np.array( + [[False, True, True], [True, True, True], [True, True, True]], dtype=bool + ) + + labels, count = _label(np.array([[True, False], [False, True]], dtype=bool)) + distance = _distance_transform_edt(mask) + dilated = _binary_dilation(np.array([[False, True, False]], dtype=bool), 1) + + assert count == 2 + assert labels.tolist() == [[1, 0], [0, 2]] + assert np.allclose(distance, [[0, 1, 2], [1, 2**0.5, 5**0.5], [2, 5**0.5, 8**0.5]]) + assert dilated.tolist() == [[True, True, True]] + + def test_crop_edge_masks_are_rejected_unless_they_reach_the_image_edge(): cropped = np.ones((20, 30), dtype=bool) at_image_edge = np.zeros((20, 30), dtype=bool) @@ -318,7 +336,18 @@ def test_coverage_prompt_points_selects_the_centre_of_a_large_empty_region(): ) assert points - assert all(x >= 16 for x, _y in points) + assert points == [ + (27, 20), + (27, 10), + (25, 25), + (25, 15), + (25, 5), + (20, 27), + (20, 20), + (20, 15), + (20, 10), + (20, 4), + ] def test_residual_points_use_summed_rgb_difference_at_the_paper_threshold(): diff --git a/uv.lock b/uv.lock index 30b169d9..ad711c66 100644 --- a/uv.lock +++ b/uv.lock @@ -3204,11 +3204,6 @@ graphviz = [ { name = "graphviz" }, ] samvg = [ - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "torch" }, { name = "torchvision" }, { name = "transformers" }, @@ -3243,9 +3238,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'" }, { name = "rich", specifier = ">=14.3.3" }, { name = "ruff", marker = "extra == 'dev'" }, - { name = "scikit-learn", marker = "extra == 'samvg'", specifier = ">=1.3.0" }, { name = "scikit-learn", marker = "extra == 'vision'", specifier = ">=1.3.0" }, - { name = "scipy", marker = "extra == 'samvg'", specifier = ">=1.11.0" }, { name = "scipy", marker = "extra == 'vision'", specifier = ">=1.11.0" }, { name = "torch", marker = "extra == 'samvg'", specifier = ">=2.0.0", index = "https://download.pytorch.org/whl/cu126" }, { name = "torch", marker = "extra == 'vision'", specifier = ">=2.0.0", index = "https://download.pytorch.org/whl/cu126" }, From b6a9892aa1c3477ba500c081ee664b649860d12c Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 12:49:29 +0200 Subject: [PATCH 08/57] feat: fit mixed SVG primitives locally --- src/vectrify/formats/svg/plugin.py | 24 ++----- src/vectrify/refine/paths.py | 109 +++++++++++++++++++++++++++-- tests/formats/svg/test_plugin.py | 38 +++++----- 3 files changed, 129 insertions(+), 42 deletions(-) diff --git a/src/vectrify/formats/svg/plugin.py b/src/vectrify/formats/svg/plugin.py index 89f5891a..aab748ab 100644 --- a/src/vectrify/formats/svg/plugin.py +++ b/src/vectrify/formats/svg/plugin.py @@ -29,9 +29,9 @@ PATH_FIT, UnsupportedPathError, fit_available, - fit_opaque_fills_locally, - fit_random_group, + fit_svg_primitives_locally, fittable_opaque_fills, + fittable_strokes, ) log = logging.getLogger(__name__) @@ -159,28 +159,18 @@ def mutate( if reference_png is None or not fit_available(): return content, PATH_FIT try: - # SAMVG seeds are opaque closed fills, so they use the exact - # analytic CUDA fitter. The older sampled operator remains - # the style-specific path for stroked cubic drawings. - if fittable_opaque_fills(content): + if fittable_opaque_fills(content) or fittable_strokes(content): return ( - fit_opaque_fills_locally( + fit_svg_primitives_locally( content, reference_png, + rasterize=lambda svg, w, h: self.rasterize(svg, w, h), + weights=targets, gpu_gate=self.gpu_gate, ), PATH_FIT, ) - return ( - fit_random_group( - content, - reference_png, - rasterize=lambda svg, w, h: self.rasterize(svg, w, h), - weights=targets, - gpu_gate=self.gpu_gate, - ), - PATH_FIT, - ) + raise UnsupportedPathError("no supported SVG primitive to fit") except UnsupportedPathError as exc: log.debug(f"Nothing to fit: {exc}") return content, PATH_FIT diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 28e83140..5fbd8456 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1270,7 +1270,9 @@ def _fill_rgb(value: str | None) -> tuple[float, float, float] | None: ) -def _composite_opaque_fills(alphas: Any, colours: Any) -> Any: +def _composite_opaque_fills( + alphas: Any, colours: Any, backdrop: Any | None = None +) -> Any: """Composite opaque SVG fills in document order without a layer loop. Each layer contributes its premultiplied colour through the product of the @@ -1283,9 +1285,12 @@ def _composite_opaque_fills(alphas: Any, colours: Any) -> Any: transparency = 1 - alphas above_inclusive = torch.cumprod(transparency.flip(0), dim=0).flip(0) above = torch.cat((above_inclusive[1:], torch.ones_like(alphas[:1])), dim=0) - return ( + painted = ( colours.clamp(0, 1)[:, None, None, :] * alphas[..., None] * above[..., None] ).sum(dim=0) + if backdrop is None: + return painted + return painted + backdrop * above_inclusive[0][..., None] @lru_cache(maxsize=1) @@ -1320,6 +1325,7 @@ def fit_filled_svg( subpixels: int = 2, monolithic: bool | None = None, curve_samples: int | None = None, + backdrop: Image.Image | None = None, ) -> str: """Optimise opaque filled cubic SVG paths against an RGB target. @@ -1431,6 +1437,18 @@ def fit_filled_svg( / 255.0, device=device, ) + under = ( + None + if backdrop is None + else torch.tensor( + np.asarray( + backdrop.convert("RGB").resize((work_width, work_height)), + dtype=np.float32, + ) + / 255.0, + device=device, + ) + ) point_optimizer = torch.optim.Adam( [control_storage], lr=point_learning_rate, fused=device == "cuda" ) @@ -1797,7 +1815,11 @@ def rasterise_multi_group( if goal.is_cuda else _composite_opaque_fills ) - rendered = composite(alpha_stack, color_storage) + rendered = ( + composite(alpha_stack, color_storage) + if under is None + else _composite_opaque_fills(alpha_stack, color_storage, under) + ) loss = ((rendered - goal) ** 2).mean() loss = ( loss @@ -1838,7 +1860,7 @@ def rasterise_multi_group( initial_alphas[index] = rasterise_multi(index, path) before: list[Any] = [] - rendered = torch.zeros_like(goal) + rendered = torch.zeros_like(goal) if under is None else under for index, alpha in enumerate(initial_alphas): assert alpha is not None colour = color_storage[index] @@ -1978,6 +2000,7 @@ def fit_opaque_fills_locally( reference_png: bytes, *, steps: int = 8, + rasterize=None, gpu_gate: Any = None, ) -> str: """Use SAMVG's analytic opaque-fill fitter as one local-search move. @@ -1992,8 +2015,84 @@ def fit_opaque_fills_locally( if not fittable_opaque_fills(svg): raise UnsupportedPathError("no opaque filled cubic paths to fit") target = Image.open(io.BytesIO(reference_png)).convert("RGB") + backdrop = None + if rasterize is not None: + import xml.etree.ElementTree as ET + + root = ET.fromstring(svg) + for element in root.iter(): + if element.tag.split("}")[-1] != "path": + continue + if _fill_rgb(element.get("fill")) is None: + continue + try: + parse_filled_cubics(element.get("d", "")) + except UnsupportedPathError: + continue + element.set("d", "") + backdrop = Image.open( + io.BytesIO( + rasterize( + ET.tostring(root, encoding="unicode"), target.width, target.height + ) + ) + ).convert("RGB") with gpu_slot(gpu_gate): - return fit_filled_svg(svg, target, steps=steps, optimisation_long_side=64) + return fit_filled_svg( + svg, + target, + steps=steps, + optimisation_long_side=64, + backdrop=backdrop, + ) + + +def fittable_strokes(svg: str) -> bool: + """Whether the legacy cubic stroke parser can select a stroke group.""" + import xml.etree.ElementTree as ET + + try: + return bool(fittable_clusters(ET.fromstring(svg))) + except ET.ParseError: + return False + + +def fit_svg_primitives_locally( + svg: str, + reference_png: bytes, + *, + rasterize, + weights: Mapping[int, float] | None = None, + steps: int = 8, + gpu_gate: Any = None, +) -> str: + """Fit analytic fills then a selected cubic-stroke group over that result. + + Each fitter rasterizes the non-active document as a fixed backdrop. This + prevents a fill from being rewarded for covering a line or editable text, + while the subsequent stroke move sees the newly fitted fills unchanged. + """ + fitted = svg + if fittable_opaque_fills(fitted): + fitted = fit_opaque_fills_locally( + fitted, + reference_png, + steps=steps, + rasterize=rasterize, + gpu_gate=gpu_gate, + ) + if fittable_strokes(fitted): + fitted = fit_random_group( + fitted, + reference_png, + rasterize=rasterize, + steps=steps, + weights=weights, + gpu_gate=gpu_gate, + ) + if fitted == svg: + raise UnsupportedPathError("no supported filled or stroked cubics to fit") + return fitted def _stroke_width(element, ancestors) -> float | None: diff --git a/tests/formats/svg/test_plugin.py b/tests/formats/svg/test_plugin.py index 9b1eead1..68928138 100644 --- a/tests/formats/svg/test_plugin.py +++ b/tests/formats/svg/test_plugin.py @@ -271,17 +271,16 @@ def test_path_fit_dispatches_opaque_fills_to_the_samvg_renderer(monkeypatch): plugin = SvgPlugin() seen = {} - def fit(svg, reference_png, *, gpu_gate): + def fit(svg, reference_png, *, rasterize, weights, gpu_gate): seen["svg"] = svg seen["reference"] = reference_png seen["gpu_gate"] = gpu_gate + assert rasterize(svg, 64, 64) + assert weights is None return svg.replace("#111111", "#ff0000") monkeypatch.setattr(plugin_module, "fit_available", lambda: True) - monkeypatch.setattr(plugin_module, "fit_opaque_fills_locally", fit) - monkeypatch.setattr( - plugin_module, "fit_random_group", lambda *_args, **_kwargs: None - ) + monkeypatch.setattr(plugin_module, "fit_svg_primitives_locally", fit) reference = plugin.rasterize(_FILLED, 64, 64) content, origin = plugin.mutate(_FILLED, operator=PATH_FIT, reference_png=reference) @@ -351,50 +350,49 @@ def test_a_full_device_skips_the_fit_instead_of_failing_the_task(): Every worker that fits holds a context of a few hundred MB and there are as many workers as cores, so running out is a normal condition, not a bug. """ - import vectrify.refine.paths as paths + import vectrify.formats.svg.plugin as plugin_module from vectrify.refine.paths import PATH_FIT plugin = SvgPlugin() png = plugin.rasterize(_STROKED, 64, 64) - original = paths.fit_random_group def out_of_memory(*_args, **_kwargs): raise RuntimeError("CUDA error: out of memory") - from vectrify.formats.svg import plugin as plugin_module - - plugin_module.fit_random_group = out_of_memory + original = plugin_module.fit_svg_primitives_locally + original_available = plugin_module.fit_available + plugin_module.fit_svg_primitives_locally = out_of_memory plugin_module.fit_available = lambda: True try: content, origin = plugin.mutate(_STROKED, operator=PATH_FIT, reference_png=png) finally: - plugin_module.fit_random_group = original - plugin_module.fit_available = paths.fit_available + plugin_module.fit_svg_primitives_locally = original + plugin_module.fit_available = original_available assert content == _STROKED assert origin == PATH_FIT def test_an_unrelated_failure_in_the_fit_is_not_swallowed(): - import vectrify.refine.paths as paths from vectrify.formats.svg import plugin as plugin_module from vectrify.refine.paths import PATH_FIT plugin = SvgPlugin() png = plugin.rasterize(_STROKED, 64, 64) - original = plugin_module.fit_random_group + original = plugin_module.fit_svg_primitives_locally + original_available = plugin_module.fit_available def bug(*_args, **_kwargs): raise ValueError("something genuinely wrong") - plugin_module.fit_random_group = bug + plugin_module.fit_svg_primitives_locally = bug plugin_module.fit_available = lambda: True try: with pytest.raises(ValueError, match="genuinely wrong"): plugin.mutate(_STROKED, operator=PATH_FIT, reference_png=png) finally: - plugin_module.fit_random_group = original - plugin_module.fit_available = paths.fit_available + plugin_module.fit_svg_primitives_locally = original + plugin_module.fit_available = original_available def test_path_fit_receives_the_shared_gpu_gate(): @@ -407,7 +405,7 @@ class Gate: plugin = SvgPlugin() plugin.gpu_gate = gate = Gate() png = plugin.rasterize(_STROKED, 64, 64) - original_fit = plugin_module.fit_random_group + original_fit = plugin_module.fit_svg_primitives_locally original_available = plugin_module.fit_available seen = {} @@ -415,12 +413,12 @@ def fit(*args, **kwargs): seen["gpu_gate"] = kwargs["gpu_gate"] return args[0] - plugin_module.fit_random_group = fit + plugin_module.fit_svg_primitives_locally = fit plugin_module.fit_available = lambda: True try: plugin.mutate(_STROKED, operator=PATH_FIT, reference_png=png) finally: - plugin_module.fit_random_group = original_fit + plugin_module.fit_svg_primitives_locally = original_fit plugin_module.fit_available = original_available assert seen["gpu_gate"] is gate From ff523c020859befdb43fb72355cd8a0f4b8cbd3a Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 21:26:03 +0200 Subject: [PATCH 09/57] feat: accelerate SAMVG refinement --- README.md | 23 + scripts/bench_samvg_two_phase.py | 221 +++++++ src/vectrify/refine/_samvg_cuda.cu | 155 +++++ src/vectrify/refine/cuda_renderer.py | 56 ++ src/vectrify/refine/paths.py | 405 ++++++++++-- src/vectrify/refine/samvg.py | 890 ++++++++++++++++++++------- tests/refine/test_fidelity.py | 2 +- tests/refine/test_filled_paths.py | 65 +- tests/refine/test_samvg.py | 242 +++++++- 9 files changed, 1777 insertions(+), 282 deletions(-) create mode 100644 scripts/bench_samvg_two_phase.py diff --git a/README.md b/README.md index 5a5bbeeb..93c2a0c8 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,29 @@ inspired by SAMVG, not an installation of the unreleased research code. Use `--no-samvg-seed` to skip it; the feature is currently available for SVG output only. +SAM inputs default to a 1024px maximum side, the model's native encoder size; +the returned masks are restored to the target's original canvas before tracing. +Set `VECTRIFY_SAMVG_MAX_SIDE` to choose another cap, or pass `max_side=None` to +the Python API to opt out explicitly. + +Automatic SAM masks retain the dissertation's 32×32 prompt grid but decode 64 +prompts per CUDA batch in FP16 by default. Set `VECTRIFY_SAMVG_POINTS_PER_BATCH` +for a larger-memory GPU; full-resolution mask filtering remains on CPU so the +batch does not consume the renderer's CUDA memory. + The default candidate is deliberately the segmentation-and-tracing seed only; it does not run the path optimiser. This keeps seed quality measurable without mixing in local refinement. + +For the dissertation-style two-phase measurement (initial fit, residual prompts, +and recovery fit), build with the optional native CUDA renderer and run: + +```sh +VECTRIFY_BUILD_SAMVG_CUDA=1 uv build --wheel --no-build-isolation +uv pip install --force-reinstall --no-deps dist/vectrify-*.whl +.venv/bin/python scripts/bench_samvg_two_phase.py --cat +``` + +`--all` also evaluates every benchmark target and the connect-the-dots duck. +Each target directory contains the five stage rasters, SVGs, a gallery, +pixel-error table, and per-bounded-group CUDA memory/timing data. diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py new file mode 100644 index 00000000..69bb8c48 --- /dev/null +++ b/scripts/bench_samvg_two_phase.py @@ -0,0 +1,221 @@ +"""Run and record SAMVG's two-phase segmentation, fit, and recovery process. + +Examples: + uv run python scripts/bench_samvg_two_phase.py --cat + uv run python scripts/bench_samvg_two_phase.py --all + +The native CUDA extension must be available for the 1024px cat workload. The +script deliberately uses the regular SAMVG masks and fixed-16-segment tracer; +it only bounds the differentiable fit to one spatial fill group at a time. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import xml.etree.ElementTree as ET +from pathlib import Path +from time import perf_counter + +import numpy as np +from PIL import Image, ImageDraw + +from vectrify.formats.svg.plugin import SvgPlugin +from vectrify.refine.paths import fit_filled_svg_bounded +from vectrify.refine.samvg import ( + _append_layers, + _mse, + _render_layers, + _render_svg, + filter_by_impact, + prompted_masks, + residual_prompt_points, + retrieve_layers, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def _path_count(svg: str) -> int: + return sum( + element.tag.split("}")[-1] == "path" for element in ET.fromstring(svg).iter() + ) + + +def _l1(target: Image.Image, rendered: Image.Image) -> float: + return float( + np.abs( + np.asarray(target.convert("RGB"), dtype=np.float32) / 255.0 + - np.asarray(rendered.convert("RGB"), dtype=np.float32) / 255.0 + ).mean() + ) + + +def _write_gallery(images: list[tuple[str, Image.Image]], destination: Path) -> None: + width = max(image.width for _name, image in images) + height = max(image.height for _name, image in images) + gallery = Image.new("RGB", (width * len(images), height + 28), "white") + labels = ImageDraw.Draw(gallery) + for index, (name, image) in enumerate(images): + gallery.paste(image.convert("RGB"), (index * width, 28)) + labels.text((index * width + 4, 6), name, fill="black") + gallery.save(destination) + + +def _fit_if_improved( + svg: str, + target: Image.Image, + plugin: SvgPlugin, + steps: int, +) -> tuple[str, Image.Image, list[dict[str, int | float]], bool]: + before = _render_svg(svg, target, plugin.rasterize) + measurements: list[dict[str, int | float]] = [] + candidate = fit_filled_svg_bounded( + svg, + target, + rasterize=plugin.rasterize, + steps=steps, + measurements=measurements, + ) + after = _render_svg(candidate, target, plugin.rasterize) + if _mse(target, after) <= _mse(target, before): + return candidate, after, measurements, True + return svg, before, measurements, False + + +def run_target( + target_path: Path, + output: Path, + *, + steps: int, + reference_svg: Path | None = None, +) -> None: + target = Image.open(target_path).convert("RGB") + plugin = SvgPlugin() + destination = output / target_path.stem + destination.mkdir(parents=True, exist_ok=True) + started = perf_counter() + layers = retrieve_layers(target) + initial = _append_layers( + f'', + layers, + 16, + hybrid_strokes=False, + ) + first, first_render, first_measurements, first_accepted = _fit_if_improved( + initial, target, plugin, steps + ) + points = residual_prompt_points(target, first_render) + _canvas, coverage = _render_layers((target.height, target.width), layers) + added = filter_by_impact( + target, + prompted_masks(target, points), + existing=layers, + initial_canvas=np.asarray(first_render, dtype=np.uint8), + initial_coverage=coverage, + )[len(layers) :] + recovery = _append_layers(first, added, 16, hybrid_strokes=False) + final, final_render, final_measurements, final_accepted = _fit_if_improved( + recovery, target, plugin, steps + ) + stages = [ + ("target", target, None), + ("first-seed", _render_svg(initial, target, plugin.rasterize), initial), + ("first-fit", first_render, first), + ( + "residual-recovery", + _render_svg(recovery, target, plugin.rasterize), + recovery, + ), + ("final-fit", final_render, final), + ] + if reference_svg is not None: + reference = _render_svg(reference_svg.read_text(), target, plugin.rasterize) + stages.append(("reference-svg", reference, reference_svg.read_text())) + rows = [] + for name, rendered, svg in stages: + rendered.save(destination / f"{name}.png") + if svg is not None: + (destination / f"{name}.svg").write_text(svg) + rows.append( + { + "stage": name, + "l1": _l1(target, rendered), + "mse": _mse(target, rendered), + "paths": _path_count(svg) if svg is not None else 0, + } + ) + _write_gallery( + [(name, image) for name, image, _svg in stages], destination / "gallery.png" + ) + with (destination / "stages.csv").open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["stage", "l1", "mse", "paths"]) + writer.writeheader() + writer.writerows(rows) + measurements = [ + {"phase": "first-fit", **measurement} for measurement in first_measurements + ] + [{"phase": "final-fit", **measurement} for measurement in final_measurements] + (destination / "fit-groups.json").write_text(json.dumps(measurements, indent=2)) + (destination / "summary.json").write_text( + json.dumps( + { + "target": str(target_path), + "first_fit_accepted": first_accepted, + "final_fit_accepted": final_accepted, + "initial_layers": len(layers), + "residual_layers": len(added), + "wall_seconds": perf_counter() - started, + "stages": rows, + }, + indent=2, + ) + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--target", type=Path, action="append", default=[]) + parser.add_argument( + "--reference-svg", + type=Path, + help="Reference SVG to Cairo-rasterize alongside a single target.", + ) + parser.add_argument("--cat", action="store_true") + parser.add_argument( + "--all", action="store_true", help="Run cat, duck, and all bench targets." + ) + parser.add_argument( + "--output", type=Path, default=ROOT / "bench/results/samvg-two-phase" + ) + parser.add_argument("--steps", type=int, default=500) + args = parser.parse_args() + targets = list(args.target) + if args.cat or args.all: + targets.append(Path("/tmp/SAMVG_thesis/cat1024.jpg")) + if args.all: + targets.extend(sorted((ROOT / "bench/cases").glob("*/target.png"))) + targets.append(ROOT / "connect-the-dots-little-duck.png") + if not targets: + parser.error("give --target, --cat, or --all") + if args.steps < 1: + parser.error("--steps must be positive") + if args.reference_svg is not None and len(targets) != 1: + parser.error("--reference-svg requires exactly one target") + for target in targets: + reference_svg = args.reference_svg + if target == Path("/tmp/SAMVG_thesis/cat1024.jpg"): + candidate = target.with_suffix(".svg") + if candidate.exists(): + reference_svg = candidate + run_target( + target, + args.output, + steps=args.steps, + reference_svg=reference_svg, + ) + + +if __name__ == "__main__": + main() diff --git a/src/vectrify/refine/_samvg_cuda.cu b/src/vectrify/refine/_samvg_cuda.cu index f0739b3a..847d77ed 100644 --- a/src/vectrify/refine/_samvg_cuda.cu +++ b/src/vectrify/refine/_samvg_cuda.cu @@ -232,6 +232,128 @@ __global__ void coverage_backward_kernel(const float* controls, const float* ups } } +// The stroke is the union of discs centred along its cubics. Clamping the +// closest-point parameter to [0, 1] makes the end discs round caps, while the +// minimum over adjacent cubics gives round joins without separate join code. +__device__ inline void closest_stroke_point(const float* path, float px, float py, + int& best_cubic, float& best_t, + float& best_x, float& best_y, + float& best_distance_sq) { + best_distance_sq = 1e30f; + best_cubic = 0; best_t = 0.f; best_x = path[0]; best_y = path[1]; + for (int cubic = 0; cubic < kCubics; ++cubic) { + for (int seed = 0; seed < 5; ++seed) { + float t = .25f * seed; + for (int iteration = 0; iteration < 3; ++iteration) { + const float qx = cubic_component(path, cubic, t, 0) - px; + const float qy = cubic_component(path, cubic, t, 1) - py; + const float dx = cubic_derivative(path, cubic, t, 0); + const float dy = cubic_derivative(path, cubic, t, 1); + t = fminf(1.f, fmaxf(0.f, t - (qx*dx + qy*dy) / (dx*dx + dy*dy + 1e-6f))); + } + const float qx = cubic_component(path, cubic, t, 0); + const float qy = cubic_component(path, cubic, t, 1); + const float dx = qx - px, dy = qy - py; + const float distance_sq = dx*dx + dy*dy; + if (distance_sq < best_distance_sq) { + best_distance_sq = distance_sq; best_cubic = cubic; best_t = t; + best_x = qx; best_y = qy; + } + } + } +} + +__global__ void stroke_forward_kernel(const float* controls, const float* widths, + float* output, int batches, int height, int width, + int subpixels, float x_base, float y_base) { + const int pixels = height * width, batch = blockIdx.x; + if (batch >= batches) return; + const float* path = controls + batch * kCubics * 8; + const float radius = fmaxf(0.f, widths[batch]) * .5f; + __shared__ float bounds[4]; + path_bounds(path, bounds); + for (int pixel = threadIdx.x; pixel < pixels; pixel += blockDim.x) { + const float base_x = x_base + float(pixel % width); + const float base_y = y_base + float(pixel / width); + float covered = 0.f; + if (base_x >= bounds[0] - radius - 2.f && base_x <= bounds[2] + radius + 2.f && + base_y >= bounds[1] - radius - 2.f && base_y <= bounds[3] + radius + 2.f) { + for (int subpixel = 0; subpixel < subpixels * subpixels; ++subpixel) { + const float px = base_x + (float(subpixel % subpixels) + .5f) / subpixels; + const float py = base_y + (float(subpixel / subpixels) + .5f) / subpixels; + int cubic; float t, qx, qy, distance_sq; + closest_stroke_point(path, px, py, cubic, t, qx, qy, distance_sq); + const float distance = sqrtf(distance_sq + 1e-12f); + covered += 1.f / (1.f + expf((distance - radius) / .25f)); + } + } + output[batch * pixels + pixel] = covered / float(subpixels * subpixels); + } +} + +__global__ void stroke_backward_kernel(const float* controls, const float* widths, + const float* upstream, float* gradients, + float* width_gradients, int batches, int height, + int width, int subpixels, float x_base, float y_base) { + const int pixels = height * width, batch = blockIdx.x; + if (batch >= batches) return; + const float* path = controls + batch * kCubics * 8; + float* gradient = gradients + batch * kCubics * 8; + const float radius = fmaxf(0.f, widths[batch]) * .5f; + __shared__ float bounds[4]; + __shared__ float reduction[8][256]; + __shared__ float width_reduction[256]; + float accumulated[kCubics * 8] = {0.f}; + float accumulated_width = 0.f; + path_bounds(path, bounds); + for (int pixel = threadIdx.x; pixel < pixels; pixel += blockDim.x) { + const float base_x = x_base + float(pixel % width); + const float base_y = y_base + float(pixel / width); + if (base_x < bounds[0] - radius - 2.f || base_x > bounds[2] + radius + 2.f || + base_y < bounds[1] - radius - 2.f || base_y > bounds[3] + radius + 2.f) continue; + const float d_output = upstream[batch * pixels + pixel] / float(subpixels * subpixels); + for (int subpixel = 0; subpixel < subpixels * subpixels; ++subpixel) { + const float px = base_x + (float(subpixel % subpixels) + .5f) / subpixels; + const float py = base_y + (float(subpixel / subpixels) + .5f) / subpixels; + int cubic; float t, qx, qy, distance_sq; + closest_stroke_point(path, px, py, cubic, t, qx, qy, distance_sq); + const float distance = sqrtf(distance_sq + 1e-12f); + const float alpha = 1.f / (1.f + expf((distance - radius) / .25f)); + const float edge = d_output * alpha * (1.f - alpha) / .25f; + const float u = 1.f - t; + const float basis[4] = {u*u*u, 3.f*u*u*t, 3.f*u*t*t, t*t*t}; + for (int control = 0; control < 4; ++control) { + const int offset = cubic * 8 + control * 2; + accumulated[offset] -= edge * (qx - px) * basis[control] / distance; + accumulated[offset + 1] -= edge * (qy - py) * basis[control] / distance; + } + accumulated_width += edge * .5f; + } + } + for (int cubic = 0; cubic < kCubics; ++cubic) { + for (int component = 0; component < 8; ++component) + reduction[component][threadIdx.x] = accumulated[cubic * 8 + component]; + __syncthreads(); + for (int stride = blockDim.x / 2; stride > 0; stride /= 2) { + if (threadIdx.x < stride) + for (int component = 0; component < 8; ++component) + reduction[component][threadIdx.x] += reduction[component][threadIdx.x + stride]; + __syncthreads(); + } + if (threadIdx.x == 0) + for (int component = 0; component < 8; ++component) + gradient[cubic * 8 + component] = reduction[component][0]; + __syncthreads(); + } + width_reduction[threadIdx.x] = accumulated_width; + __syncthreads(); + for (int stride = blockDim.x / 2; stride > 0; stride /= 2) { + if (threadIdx.x < stride) width_reduction[threadIdx.x] += width_reduction[threadIdx.x + stride]; + __syncthreads(); + } + if (threadIdx.x == 0) width_gradients[batch] = width_reduction[0]; +} + __device__ inline void contours_bounds(const float* controls, int first, int last, float* bounds) { if (threadIdx.x == 0) { const float* first_path = controls + first * kCubics * 8; @@ -617,6 +739,37 @@ torch::Tensor coverage_backward(torch::Tensor controls, torch::Tensor upstream, return gradients; } +torch::Tensor stroke_forward(torch::Tensor controls, torch::Tensor widths, int64_t height, + int64_t width, int64_t subpixels, double x_origin, + double y_origin) { + TORCH_CHECK(controls.is_cuda() && controls.scalar_type() == torch::kFloat32); + TORCH_CHECK(widths.is_cuda() && widths.scalar_type() == torch::kFloat32); + TORCH_CHECK(widths.dim() == 1 && widths.size(0) == controls.size(0)); + TORCH_CHECK(subpixels >= 1 && subpixels <= 4); + at::cuda::CUDAGuard guard(controls.device()); + auto output = torch::zeros({controls.size(0), height, width}, controls.options()); + stroke_forward_kernel<<>>( + controls.data_ptr(), widths.data_ptr(), output.data_ptr(), + controls.size(0), height, width, subpixels, float(x_origin), float(y_origin)); + C10_CUDA_KERNEL_LAUNCH_CHECK(); + return output; +} + +std::vector stroke_backward(torch::Tensor controls, torch::Tensor widths, + torch::Tensor upstream, int64_t height, + int64_t width, int64_t subpixels, + double x_origin, double y_origin) { + at::cuda::CUDAGuard guard(controls.device()); + auto gradients = torch::zeros_like(controls); + auto width_gradients = torch::zeros_like(widths); + stroke_backward_kernel<<>>( + controls.data_ptr(), widths.data_ptr(), upstream.data_ptr(), + gradients.data_ptr(), width_gradients.data_ptr(), controls.size(0), + height, width, subpixels, float(x_origin), float(y_origin)); + C10_CUDA_KERNEL_LAUNCH_CHECK(); + return {gradients, width_gradients}; +} + torch::Tensor multi_coverage_forward(torch::Tensor controls, torch::Tensor offsets, int64_t height, int64_t width, int64_t subpixels, double x_origin, double y_origin, bool evenodd) { @@ -693,6 +846,8 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { m.def("backwards", &backwards); m.def("coverage_forward", &coverage_forward); m.def("coverage_backward", &coverage_backward); + m.def("stroke_forward", &stroke_forward); + m.def("stroke_backward", &stroke_backward); m.def("multi_coverage_forward", &multi_coverage_forward); m.def("multi_coverage_forward_topology", &multi_coverage_forward_topology); m.def("multi_coverage_backward", &multi_coverage_backward); diff --git a/src/vectrify/refine/cuda_renderer.py b/src/vectrify/refine/cuda_renderer.py index 5cb25893..3dce9031 100644 --- a/src/vectrify/refine/cuda_renderer.py +++ b/src/vectrify/refine/cuda_renderer.py @@ -184,6 +184,62 @@ def backward(ctx: Any, *upstreams: Any) -> Any: return Coverage.apply(controls) +def stroke_coverage( + controls: Any, + widths: Any, + box: tuple[int, int, int, int], + *, + subpixels: int = 2, +) -> Any | None: + """Differentiable cubic-tube coverage with round caps and joins on CUDA.""" + import torch + + extension = _extension() + if ( + extension is None + or not controls.is_cuda + or controls.dtype != torch.float32 + or controls.ndim != 4 + or controls.shape[1:] != (16, 4, 2) + or not widths.is_cuda + or widths.dtype != torch.float32 + or widths.ndim != 1 + or widths.shape[0] != controls.shape[0] + or subpixels not in {1, 2, 4} + ): + return None + left, top, right, bottom = box + height, width = bottom - top, right - left + + class StrokeCoverage(torch.autograd.Function): + @staticmethod + def forward(ctx, values, stroke_widths): + values = values.contiguous() + stroke_widths = stroke_widths.contiguous() + ctx.save_for_backward(values, stroke_widths) + return extension.stroke_forward( + values, stroke_widths, height, width, subpixels, left, top + ) + + @staticmethod + def backward(ctx: Any, *upstreams: Any) -> Any: + values, stroke_widths = ctx.saved_tensors + upstream = upstreams[0] + control_gradients, width_gradients = extension.stroke_backward( + values, + stroke_widths, + upstream.contiguous(), + height, + width, + subpixels, + left, + top, + ) + return control_gradients, width_gradients + + return StrokeCoverage.apply(controls, widths) + + def multi_coverage_forward( controls: Any, offsets: list[int], diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 5fbd8456..4f2d16ec 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -17,6 +17,7 @@ from collections.abc import Iterator, Mapping from contextlib import contextmanager from functools import lru_cache +from time import perf_counter from typing import Any import numpy as np @@ -234,7 +235,7 @@ def to_path_d(segments) -> str: def coverage( control: Any, - width: float, + width: float | Any, box: tuple[int, int, int, int], samples: int | None = None, softness: float = 0.25, @@ -251,6 +252,21 @@ def coverage( """ import torch + if control.is_cuda and control.shape[0] <= _FUSED_CUBICS: + from vectrify.refine.cuda_renderer import stroke_coverage + + padded = _pad_fused_cubics(control[None]) + stroke_width = ( + width.reshape(1) + if isinstance(width, torch.Tensor) + else torch.full( + (1,), float(width), dtype=control.dtype, device=control.device + ) + ) + native = stroke_coverage(padded, stroke_width, box, subpixels=2) + if native is not None: + return native[0] + if samples is None: samples = _samples_for(control) left, top, right, bottom = box @@ -334,6 +350,7 @@ def fit_group( widths: float | list[float], target: Image.Image, backdrop: Image.Image, + colours: list[tuple[float, float, float]] | None = None, size: int = 700, steps: int = 200, samples: int | None = None, @@ -343,23 +360,20 @@ def fit_group( redundancy: float = 0.15, smooth: float = 0.0, anchor: float = 0.001, -) -> tuple[list[str], float, float]: +) -> tuple[list[str], list[float], list[tuple[float, float, float]], float, float]: """Fit every path in *paths* together, returning new path data and losses. *backdrop* is the drawing rendered with this group removed; *target* is the - picture being matched. Both are greyscale and the same size as the canvas. + picture being matched. Both are RGB and the same size as the canvas. *pinned* names welded vertices that must not move: a point this set shares with a path outside it. Without them a partial fit tears the drawing at exactly the junctions welding exists to hold -- the fitted side walks away while the neighbour it meets stays put. - The paths composite as a soft union -- one minus the product of their - complements -- which is what "any of these strokes covers this pixel" means - and what the real renderer shows. A redundancy term charges for pixels more - than one stroke covers, because the union alone is indifferent between three - strokes doing a third of the work each and one doing all of it while the - other two collapse onto it. + Each path is composited in SVG document order over the fixed backdrop. This + retains different stroke colours and makes width, colour, and cubic controls + jointly differentiable parameters of the same local move. """ import torch @@ -371,7 +385,7 @@ def fit_group( def crop(image: Image.Image) -> Any: - array = np.asarray(image.convert("L").resize((size, size)), dtype=np.float32) + array = np.asarray(image.convert("RGB").resize((size, size)), dtype=np.float32) return torch.tensor(array[top:bottom, left:right] / 255.0, device=device) goal = crop(target) @@ -385,12 +399,25 @@ def crop(image: Image.Image) -> Any: if isinstance(widths, int | float) else list(widths) ) + colour_values = colours or [(0.0, 0.0, 0.0)] * len(paths) + if len(colour_values) != len(paths): + raise ValueError("each stroked path needs one RGB colour") welded, index = weld(chains) vertices = torch.tensor(welded, device=device, dtype=torch.float32) vertices.requires_grad_(True) + stroke_widths = torch.tensor(each, device=device, dtype=torch.float32) + stroke_widths.requires_grad_(True) + stroke_colours = torch.tensor(colour_values, device=device, dtype=torch.float32) + stroke_colours.requires_grad_(True) original = vertices.detach().clone() rows = [torch.tensor(r, device=device, dtype=torch.long) for r in index] - optimizer = torch.optim.Adam([vertices], lr=learning_rate) + optimizer = torch.optim.Adam( + [ + {"params": [vertices], "lr": learning_rate}, + {"params": [stroke_widths], "lr": learning_rate * 0.1}, + {"params": [stroke_colours], "lr": learning_rate * 0.05}, + ] + ) def chain_of(row: Any) -> Any: return vertices[row] @@ -402,7 +429,7 @@ def controls_of(chain: Any) -> Any: mask = _focus_mask( [ coverage(controls_of(chain_of(r)), w, box, samples=samples) - for r, w in zip(rows, each, strict=True) + for r, w in zip(rows, stroke_widths, strict=True) ], int(margin), ) @@ -412,12 +439,15 @@ def controls_of(chain: Any) -> Any: for step in range(steps): covers = [ coverage(controls_of(chain_of(r)), w, box, samples=samples) - for r, w in zip(rows, each, strict=True) + for r, w in zip(rows, stroke_widths, strict=True) ] stacked = torch.stack(covers) - union = 1 - torch.prod(1 - stacked, dim=0) - drawn = under * (1 - union) - loss = ((drawn - goal).abs() * weight).sum() + drawn = under + for alpha, colour in zip(stacked, stroke_colours, strict=True): + drawn = drawn * (1 - alpha[..., None]) + ( + colour.clamp(0, 1) * alpha[..., None] + ) + loss = ((drawn - goal).abs() * weight[..., None]).sum() if redundancy: loss = ( loss + redundancy * ((stacked.sum(0) - 1).clamp_min(0) * weight).sum() @@ -436,6 +466,9 @@ def controls_of(chain: Any) -> Any: optimizer.zero_grad() loss.backward() optimizer.step() + with torch.no_grad(): + stroke_widths.clamp_(min=0.1) + stroke_colours.clamp_(0, 1) if pinned: with torch.no_grad(): held = torch.tensor(sorted(pinned), device=device, dtype=torch.long) @@ -443,7 +476,13 @@ def controls_of(chain: Any) -> Any: last = float(loss.detach()) fitted = [knots_to_path_d(chain_of(r).detach().cpu().tolist()) for r in rows] - return fitted, first, last + return ( + fitted, + stroke_widths.detach().cpu().tolist(), + [tuple(colour) for colour in stroke_colours.detach().cpu().tolist()], + first, + last, + ) def _fill_winding( @@ -1995,15 +2034,161 @@ def fittable_opaque_fills(svg: str) -> bool: return False +_FillBounds = tuple[float, float, float, float] +_FittableFill = tuple[int, Any, _FillBounds] + + +def _fittable_fill_elements(root) -> list[_FittableFill]: + """Return document-indexed opaque fills with conservative control bounds.""" + entries = [] + for document_index, element in enumerate(root.iter()): + if element.tag.split("}")[-1] != "path" or not element.get("d"): + continue + if _fill_rgb(element.get("fill")) is None: + continue + try: + contours = parse_filled_cubics(element.get("d", "")) + except UnsupportedPathError: + continue + if element.get("fill-rule", "nonzero").strip().lower() not in { + "evenodd", + "nonzero", + }: + continue + points = [point for contour in contours for cubic in contour for point in cubic] + entries.append( + ( + document_index, + element, + ( + min(point[0] for point in points), + min(point[1] for point in points), + max(point[0] for point in points), + max(point[1] for point in points), + ), + ) + ) + return entries + + +def _select_fill_group( + entries: list[_FittableFill], + *, + weights: Mapping[int, float] | None, + maximum_paths: int, +) -> set[int]: + """Choose one bounded spatial fill group, biased toward attributed error.""" + if maximum_paths < 1: + raise ValueError("maximum_paths must be positive") + scores = [max(0.0, (weights or {}).get(index, 0.0)) for index, _el, _box in entries] + focal = ( + random.choices(entries, weights=scores, k=1)[0] + if sum(scores) > 0 + else random.choice(entries) + ) + focal_index, _element, (left, top, right, bottom) = focal + centre_x, centre_y = (left + right) / 2, (top + bottom) / 2 + extent = max(right - left, bottom - top, 8.0) + + def distance(entry: _FittableFill) -> tuple[int, float, int]: + ( + index, + _candidate, + ( + candidate_left, + candidate_top, + candidate_right, + candidate_bottom, + ), + ) = entry + candidate_x = (candidate_left + candidate_right) / 2 + candidate_y = (candidate_top + candidate_bottom) / 2 + overlap = not ( + candidate_right < left - extent + or candidate_left > right + extent + or candidate_bottom < top - extent + or candidate_top > bottom + extent + ) + return ( + 0 if overlap else 1, + (candidate_x - centre_x) ** 2 + (candidate_y - centre_y) ** 2, + index, + ) + + selected = sorted(entries, key=distance)[:maximum_paths] + return {index for index, _element, _box in selected} | {focal_index} + + +def fill_groups(svg: str, *, maximum_paths: int = 16) -> list[set[int]]: + """Partition opaque fills into bounded spatial groups for coordinate descent.""" + import xml.etree.ElementTree as ET + + entries = _fittable_fill_elements(ET.fromstring(svg)) + remaining = {index for index, _element, _box in entries} + groups = [] + while remaining: + focal = next(entry for entry in entries if entry[0] in remaining) + focal_index, _element, (left, top, right, bottom) = focal + centre_x, centre_y = (left + right) / 2, (top + bottom) / 2 + extent = max(right - left, bottom - top, 8.0) + + def key( + entry: _FittableFill, + bounds: _FillBounds = (left, top, right, bottom), + radius: float = extent, + centre: tuple[float, float] = (centre_x, centre_y), + ) -> tuple[int, float, int]: + ( + index, + _candidate, + ( + candidate_left, + candidate_top, + candidate_right, + candidate_bottom, + ), + ) = entry + focal_left, focal_top, focal_right, focal_bottom = bounds + focal_x, focal_y = centre + candidate_x = (candidate_left + candidate_right) / 2 + candidate_y = (candidate_top + candidate_bottom) / 2 + overlap = not ( + candidate_right < focal_left - radius + or candidate_left > focal_right + radius + or candidate_bottom < focal_top - radius + or candidate_top > focal_bottom + radius + ) + return ( + 0 if overlap else 1, + (candidate_x - focal_x) ** 2 + (candidate_y - focal_y) ** 2, + index, + ) + + group = { + index + for index, _element, _bounds in sorted( + (entry for entry in entries if entry[0] in remaining), key=key + )[:maximum_paths] + } + group.add(focal_index) + groups.append(group) + remaining -= group + return groups + + def fit_opaque_fills_locally( svg: str, reference_png: bytes, *, steps: int = 8, rasterize=None, + weights: Mapping[int, float] | None = None, + maximum_paths: int = 16, + selected_indices: set[int] | None = None, + optimisation_long_side: int | None = 64, gpu_gate: Any = None, ) -> str: - """Use SAMVG's analytic opaque-fill fitter as one local-search move. + """Fit one spatially bounded opaque-fill group as a local-search move. Unlike the legacy stroke fitter this operates on complete filled shapes, including compound paths and holes. It deliberately keeps the 64px @@ -2012,43 +2197,120 @@ def fit_opaque_fills_locally( """ from PIL import Image - if not fittable_opaque_fills(svg): - raise UnsupportedPathError("no opaque filled cubic paths to fit") target = Image.open(io.BytesIO(reference_png)).convert("RGB") - backdrop = None - if rasterize is not None: - import xml.etree.ElementTree as ET + if rasterize is None: + raise UnsupportedPathError("bounded fill fitting needs an SVG rasterizer") + import xml.etree.ElementTree as ET - root = ET.fromstring(svg) - for element in root.iter(): - if element.tag.split("}")[-1] != "path": - continue - if _fill_rgb(element.get("fill")) is None: - continue - try: - parse_filled_cubics(element.get("d", "")) - except UnsupportedPathError: - continue + original = ET.fromstring(svg) + entries = _fittable_fill_elements(original) + if not entries: + raise UnsupportedPathError("no opaque filled cubic paths to fit") + selected_indices = selected_indices or _select_fill_group( + entries, weights=weights, maximum_paths=maximum_paths + ) + backdrop_root = ET.fromstring(svg) + working_root = ET.fromstring(svg) + for index, element in enumerate(backdrop_root.iter()): + if index in selected_indices: element.set("d", "") - backdrop = Image.open( - io.BytesIO( - rasterize( - ET.tostring(root, encoding="unicode"), target.width, target.height - ) + for index, element in enumerate(working_root.iter()): + if index not in selected_indices and element.tag.split("}")[-1] == "path": + element.set("d", "") + backdrop = Image.open( + io.BytesIO( + rasterize( + ET.tostring(backdrop_root, encoding="unicode"), + target.width, + target.height, ) - ).convert("RGB") + ) + ).convert("RGB") with gpu_slot(gpu_gate): - return fit_filled_svg( - svg, + fitted = fit_filled_svg( + ET.tostring(working_root, encoding="unicode"), target, steps=steps, - optimisation_long_side=64, + optimisation_long_side=optimisation_long_side, backdrop=backdrop, ) + fitted_root = ET.fromstring(fitted) + fitted_by_index = dict(enumerate(fitted_root.iter())) + for index, element in enumerate(original.iter()): + if index not in selected_indices: + continue + updated = fitted_by_index[index] + element.set("d", updated.get("d", "")) + element.set("fill", updated.get("fill", element.get("fill", ""))) + return ET.tostring(original, encoding="unicode") + + +def fit_filled_svg_bounded( + svg: str, + target: Image.Image, + *, + rasterize, + steps: int = 500, + maximum_paths: int = 16, + gpu_gate: Any = None, + measurements: list[dict[str, int | float]] | None = None, +) -> str: + """Run one full SAMVG fill phase as bounded spatial coordinate descent. + + ``steps`` is the per-group phase budget. Coordinate descent needs to give + every group the same fitting opportunity that it would have had in the + original global graph; splitting that budget between groups loses detail. + It consequently trades wall time for a strictly bounded differentiable + graph. When requested, ``measurements`` receives one timing and CUDA-peak + record for each local group mutation. + """ + if steps < 1: + raise ValueError("steps must be positive") + groups = fill_groups(svg, maximum_paths=maximum_paths) + if not groups: + raise UnsupportedPathError("no opaque filled cubic paths to optimise") + encoded = io.BytesIO() + target.convert("RGB").save(encoded, format="PNG") + fitted = svg + for index, group in enumerate(groups): + peak_before = 0 + try: + import torch + + if torch.cuda.is_available(): + torch.cuda.reset_peak_memory_stats() + peak_before = int(torch.cuda.max_memory_allocated()) + except ImportError: + torch = None # type: ignore[assignment] + started = perf_counter() + fitted = fit_opaque_fills_locally( + fitted, + encoded.getvalue(), + steps=steps, + rasterize=rasterize, + maximum_paths=maximum_paths, + selected_indices=group, + optimisation_long_side=None, + gpu_gate=gpu_gate, + ) + if measurements is not None: + peak = peak_before + if torch is not None and torch.cuda.is_available(): + torch.cuda.synchronize() + peak = int(torch.cuda.max_memory_allocated()) + measurements.append( + { + "group": index, + "paths": len(group), + "seconds": perf_counter() - started, + "peak_cuda_bytes": peak, + } + ) + return fitted def fittable_strokes(svg: str) -> bool: - """Whether the legacy cubic stroke parser can select a stroke group.""" + """Whether the unified cubic-stroke fitter can select a stroke group.""" import xml.etree.ElementTree as ET try: @@ -2066,33 +2328,33 @@ def fit_svg_primitives_locally( steps: int = 8, gpu_gate: Any = None, ) -> str: - """Fit analytic fills then a selected cubic-stroke group over that result. + """Fit one selected fill or stroke primitive group over fixed SVG context. Each fitter rasterizes the non-active document as a fixed backdrop. This prevents a fill from being rewarded for covering a line or editable text, while the subsequent stroke move sees the newly fitted fills unchanged. """ - fitted = svg - if fittable_opaque_fills(fitted): - fitted = fit_opaque_fills_locally( - fitted, + fills = fittable_opaque_fills(svg) + strokes = fittable_strokes(svg) + if fills and (not strokes or random.random() < 0.5): + return fit_opaque_fills_locally( + svg, reference_png, steps=steps, rasterize=rasterize, + weights=weights, gpu_gate=gpu_gate, ) - if fittable_strokes(fitted): - fitted = fit_random_group( - fitted, + if strokes: + return fit_random_group( + svg, reference_png, rasterize=rasterize, steps=steps, weights=weights, gpu_gate=gpu_gate, ) - if fitted == svg: - raise UnsupportedPathError("no supported filled or stroked cubics to fit") - return fitted + raise UnsupportedPathError("no supported filled or stroked cubics to fit") def _stroke_width(element, ancestors) -> float | None: @@ -2109,6 +2371,20 @@ def _stroke_width(element, ancestors) -> float | None: return None +def _stroke_rgb( + element: Any, ancestors: list[Any] +) -> tuple[float, float, float] | None: + """Return an inherited opaque hex stroke colour, if the path paints one.""" + for node in (element, *ancestors): + raw = node.get("stroke") + if raw is None: + continue + if raw.strip().lower() == "none": + return None + return _fill_rgb(raw) + return None + + def _parents(root) -> dict[int, Any]: """id(child) -> parent, so a path can be read in the context it inherits.""" table: dict[int, Any] = {} @@ -2232,7 +2508,7 @@ def fit_random_group( paths = [p for i, p in enumerate(paths) if i in chosen] size = int(_canvas_side(root)) - target = Image.open(io.BytesIO(reference_png)).convert("L").resize((size, size)) + target = Image.open(io.BytesIO(reference_png)).convert("RGB").resize((size, size)) # The backdrop is the drawing without these paths, so the fit sees the rest # of the picture as a constant and cannot be rewarded for redrawing it. @@ -2243,24 +2519,37 @@ def fit_random_group( path.set("d", "") backdrop = Image.open( io.BytesIO(rasterize(ET.tostring(root, encoding="unicode"), size, size)) - ).convert("L") + ).convert("RGB") for path, data in zip(paths, original, strict=True): path.set("d", data) held = _shared_vertices([parse_cubics(d) for d in original], excluded) + parents = _parents(root) + colours = [ + _stroke_rgb(path, _ancestry(path, parents, root)) or (0.0, 0.0, 0.0) + for path in paths + ] with gpu_slot(gpu_gate): - fitted, _first, _last = fit_group( + fitted, fitted_widths, fitted_colours, _first, _last = fit_group( original, widths, target, backdrop, + colours, size=size, steps=steps, samples=samples, pinned=held, ) - for path, data in zip(paths, fitted, strict=True): + for path, data, width, colour in zip( + paths, fitted, fitted_widths, fitted_colours, strict=True + ): path.set("d", data) + path.set("stroke-width", f"{width:.2f}") + path.set( + "stroke", + "#" + "".join(f"{round(channel * 255):02x}" for channel in colour), + ) return ET.tostring(root, encoding="unicode") diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index c8600dba..fa0b8b39 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -16,8 +16,9 @@ import os import re import xml.etree.ElementTree as ET -from collections import defaultdict, deque +from collections import defaultdict from collections.abc import Callable +from contextlib import nullcontext from dataclasses import dataclass from typing import Any, cast @@ -30,6 +31,14 @@ # ViT-H is the paper-quality default; users who need the smaller checkpoint can # opt down without changing the package through VECTRIFY_SAMVG_MODEL. SAMVG_MODEL = os.environ.get("VECTRIFY_SAMVG_MODEL", "facebook/sam-vit-huge") +# SAM encodes images at a native 1024px long side. Keep that encoder-size cap +# as the default even when Vectrify is asked to vectorize a larger original; +# masks are restored to the original canvas before tracing. +SAMVG_MAX_SIDE = int(os.environ.get("VECTRIFY_SAMVG_MAX_SIDE", "1024")) +# This is the decoder prompt batch, not the dissertation's 32x32 sampling +# grid. 64 doubles the old 32 while leaving full-resolution-mask +# headroom on a 16 GB GPU; users with larger cards can raise it by environment. +SAMVG_POINTS_PER_BATCH = int(os.environ.get("VECTRIFY_SAMVG_POINTS_PER_BATCH", "64")) # SAMVG's own impact filter selects useful masks against the image. Retaining # AMG's score gates here discarded the small facial candidates needed by the # photo seed before that image-aware test could evaluate them. @@ -241,35 +250,72 @@ def _is_crop_edge_mask( return bool(np.any(at_crop_edge & ~at_image_edge)) +def _run_components(mask: np.ndarray) -> list[list[tuple[int, int, int]]]: + """Return 4-connected components as row spans in row-major order. + + The old breadth-first walk crossed the Python interpreter once for every + foreground pixel. SAM masks are usually broad regions, so representing + each row as contiguous runs reduces that to a small number of intervals + while retaining scipy.ndimage's 4-connected ordering. + """ + foreground = np.asarray(mask, dtype=bool) + _height, width = foreground.shape + parent = [0] + + def root(index: int) -> int: + while parent[index] != index: + parent[index] = parent[parent[index]] + index = parent[index] + return index + + def merge(left: int, right: int) -> None: + left, right = root(left), root(right) + if left != right: + parent[right] = left + + rows: list[list[tuple[int, int, int]]] = [] + previous: list[tuple[int, int, int]] = [] + for row in foreground: + padded = np.empty(width + 2, dtype=bool) + padded[0] = padded[-1] = False + padded[1:-1] = row + edges = np.flatnonzero(padded[1:] != padded[:-1]) + current: list[tuple[int, int, int]] = [] + prior = 0 + for start, end in edges.reshape(-1, 2): + while prior < len(previous) and previous[prior][1] <= start: + prior += 1 + index = len(parent) + parent.append(index) + candidate = prior + while candidate < len(previous) and previous[candidate][0] < end: + merge(index, previous[candidate][2]) + candidate += 1 + current.append((int(start), int(end), index)) + rows.append(current) + previous = current + + components: list[list[tuple[int, int, int]]] = [] + component_ids: dict[int, int] = {} + for y, runs in enumerate(rows): + for start, end, index in runs: + component = root(index) + label = component_ids.setdefault(component, len(component_ids)) + if label == len(components): + components.append([]) + components[label].append((y, start, end)) + return components + + def _label(mask: np.ndarray) -> tuple[np.ndarray, int]: - """Label 4-connected foreground components like scipy.ndimage.label.""" + """Materialize 4-connected scanline components as an integer label map.""" foreground = np.asarray(mask, dtype=bool) labels = np.zeros(foreground.shape, dtype=np.int32) - height, width = foreground.shape - count = 0 - for y, x in zip(*np.nonzero(foreground), strict=True): - if labels[y, x]: - continue - count += 1 - labels[y, x] = count - pending = deque([(int(y), int(x))]) - while pending: - row, column = pending.popleft() - for next_y, next_x in ( - (row - 1, column), - (row + 1, column), - (row, column - 1), - (row, column + 1), - ): - if ( - 0 <= next_y < height - and 0 <= next_x < width - and foreground[next_y, next_x] - and not labels[next_y, next_x] - ): - labels[next_y, next_x] = count - pending.append((next_y, next_x)) - return labels, count + components = _run_components(foreground) + for index, runs in enumerate(components, start=1): + for y, start, end in runs: + labels[y, start:end] = index + return labels, len(components) def _edt_1d(values: np.ndarray) -> np.ndarray: @@ -372,55 +418,221 @@ def _mean_shift_centres(points: np.ndarray, bandwidth: float) -> np.ndarray: return candidates[unique] -def automatic_masks(image: Image.Image) -> list[np.ndarray]: - """Retrieve SAM AMG masks with the thesis's 32-point grid and crops.""" +def _sam_image(image: Image.Image, max_side: int | None) -> tuple[Image.Image, float]: + """Bound a SAM pass while retaining masks in the original canvas space.""" + image = image.convert("RGB") + if max_side is None: + return image, 1.0 + if max_side < 1: + raise ValueError("max_side must be positive") + longest = max(image.size) + if longest <= max_side: + return image, 1.0 + scale = max_side / longest + return ( + image.resize( + (round(image.width * scale), round(image.height * scale)), + Image.Resampling.LANCZOS, + ), + scale, + ) + + +def _restore_mask(mask: np.ndarray, size: tuple[int, int]) -> np.ndarray: + """Nearest-neighbour restore keeps SAM's binary mask semantics.""" + if mask.shape == (size[1], size[0]): + return np.asarray(mask, dtype=bool) + return np.asarray( + Image.fromarray(np.asarray(mask, dtype=np.uint8) * 255).resize( + size, Image.Resampling.NEAREST + ), + dtype=bool, + ) + + +@dataclass +class _SamRuntime: + """One SAM model lifetime, including a reusable full-image embedding.""" + + generator: Any + processor: Any | None = None + image_embeddings: Any | None = None + embedding_size: tuple[int, int] | None = None + + +def _sam_runtime() -> _SamRuntime: + """Load SAM once, in half precision when CUDA is available.""" try: + import torch from transformers import pipeline except ImportError as exc: # pragma: no cover - installation-specific raise ImportError( "SAMVG requires the samvg extra. Install 'vectrify[samvg]'." ) from exc - image = image.convert("RGB") - generator = pipeline("mask-generation", model=SAMVG_MODEL, device=0) - log.info("SAMVG automatic masks: %s on %s.", SAMVG_MODEL, generator.device) + options: dict[str, Any] = {"model": SAMVG_MODEL, "device": 0} + if torch.cuda.is_available(): + options["dtype"] = torch.float16 + generator = pipeline("mask-generation", **options) + log.info( + "SAMVG automatic masks: %s on %s (%s).", + SAMVG_MODEL, + generator.device, + "fp16" if torch.cuda.is_available() else "fp32", + ) + return _SamRuntime(generator) - def masks_for(source: Image.Image) -> list[np.ndarray]: - return [ - np.asarray(mask, dtype=bool) - for mask in generator( - source, - points_per_batch=32, - points_per_crop=32, - crops_n_layers=0, - pred_iou_thresh=SAMVG_PRED_IOU_THRESH, - stability_score_thresh=SAMVG_STABILITY_SCORE_THRESH, - )["masks"] - ] + +def _sam_autocast(): + """Use Tensor Cores for inference while keeping exported masks binary.""" + import torch + + if torch.cuda.is_available(): + return torch.autocast(device_type="cuda", dtype=torch.float16) + return nullcontext() + + +def _automatic_forward(inputs: Any, runtime: _SamRuntime) -> dict[str, Any]: + """Decode on CUDA, then expand and filter masks on CPU. + + The stock Transformers pipeline expands a prompt batch to the original + image size on CUDA. At 1024px that transient allocation is larger than the + decoder itself. Its filtering sequence is unchanged here; only the + post-decoder device changes. + """ + generator = runtime.generator + input_boxes = inputs.pop("input_boxes").detach().cpu().float() + is_last = inputs.pop("is_last") + original_sizes = inputs.pop("original_sizes").detach().cpu().tolist() + reshaped_sizes = inputs.pop("reshaped_input_sizes", None) + if reshaped_sizes is not None: + reshaped_sizes = reshaped_sizes.detach().cpu().tolist() + with _sam_autocast(): + model_outputs = generator.model(**inputs) + masks = generator.image_processor.post_process_masks( + model_outputs.pred_masks.detach().cpu(), + original_sizes, + mask_threshold=0, + reshaped_input_sizes=reshaped_sizes, + binarize=False, + ) + filtered_masks, scores, boxes = generator.image_processor.filter_masks( + masks[0], + model_outputs.iou_scores.detach().cpu().float()[0], + original_sizes[0], + input_boxes[0], + SAMVG_PRED_IOU_THRESH, + SAMVG_STABILITY_SCORE_THRESH, + 0, + 1, + ) + return { + "masks": filtered_masks, + "is_last": is_last, + "boxes": boxes, + "iou_scores": scores, + } + + +def _automatic_masks_for( + source: Image.Image, + runtime: _SamRuntime, + *, + cache_embedding: bool, + points_per_batch: int = SAMVG_POINTS_PER_BATCH, +) -> list[np.ndarray]: + """Run one AMG image/crop without recomputing prompt-grid embeddings. + + Transformers' public mask-generation call already encodes an image once + per 32x32 prompt grid. For the full image we use the same pipeline stages + directly so the resulting embedding can be reused by coverage/residual + prompts. Crops intentionally retain their own embeddings. + """ + generator = runtime.generator + arguments = { + "points_per_batch": points_per_batch, + "points_per_crop": 32, + "crops_n_layers": 0, + "pred_iou_thresh": SAMVG_PRED_IOU_THRESH, + "stability_score_thresh": SAMVG_STABILITY_SCORE_THRESH, + } + # Keep a small compatibility path for mocked/older Transformers pipelines. + if not hasattr(generator, "preprocess"): + output = generator(source, **arguments) + return [np.asarray(mask, dtype=bool) for mask in output["masks"]] + + outputs = [] + for inputs in generator.preprocess( + source, + points_per_batch=points_per_batch, + points_per_crop=32, + crops_n_layers=0, + ): + # ChunkPipeline normally performs this transfer between preprocess and + # _forward. We call those stages directly to retain the embedding. + inputs = generator._ensure_tensor_on_device(inputs, device=generator.device) + embedding = inputs.get("image_embeddings") + if ( + cache_embedding + and embedding is not None + and runtime.image_embeddings is None + ): + runtime.image_embeddings = embedding + runtime.embedding_size = source.size + outputs.append(_automatic_forward(inputs, runtime)) + output = generator.postprocess(outputs) + return [np.asarray(mask, dtype=bool) for mask in output["masks"]] + + +def automatic_masks( + image: Image.Image, + *, + max_side: int | None = SAMVG_MAX_SIDE, + _runtime: _SamRuntime | None = None, +) -> list[np.ndarray]: + """Retrieve SAM AMG masks with the thesis grid, optionally size-capped.""" + original_size = image.size + image, _scale = _sam_image(image, max_side) + runtime = _runtime or _sam_runtime() # transformers' built-in crop layer tries to stack unequal crop tensors. # Run that first crop layer one crop at a time instead. Crucially, do not # pre-pad a rectangular image: the original AMG formula uses the source's # short side for overlap, and black padding changes SAM's visual context. width, height = image.size - collected = masks_for(image) - overlap = int((512 / 1500) * min(width, height)) - crop_width = math.ceil((overlap + width) / 2) - crop_height = math.ceil((overlap + height) / 2) - for x, y in { - (0, 0), - (crop_width - overlap, 0), - (0, crop_height - overlap), - (crop_width - overlap, crop_height - overlap), - }: - right, bottom = min(x + crop_width, width), min(y + crop_height, height) - crop_box = (x, y, right, bottom) - for crop_mask in masks_for(image.crop(crop_box)): - if _is_crop_edge_mask(crop_mask, crop_box, image.size): - continue - mask = np.zeros((height, width), dtype=bool) - mask[y:bottom, x:right] = crop_mask - collected.append(mask) - return collected + + def collect(points_per_batch: int) -> list[np.ndarray]: + collected = _automatic_masks_for( + image, + runtime, + cache_embedding=True, + points_per_batch=points_per_batch, + ) + overlap = int((512 / 1500) * min(width, height)) + crop_width = math.ceil((overlap + width) / 2) + crop_height = math.ceil((overlap + height) / 2) + for x, y in { + (0, 0), + (crop_width - overlap, 0), + (0, crop_height - overlap), + (crop_width - overlap, crop_height - overlap), + }: + right, bottom = min(x + crop_width, width), min(y + crop_height, height) + crop_box = (x, y, right, bottom) + for crop_mask in _automatic_masks_for( + image.crop(crop_box), + runtime, + cache_embedding=False, + points_per_batch=points_per_batch, + ): + if _is_crop_edge_mask(crop_mask, crop_box, image.size): + continue + mask = np.zeros((height, width), dtype=bool) + mask[y:bottom, x:right] = crop_mask + collected.append(mask) + return collected + + collected = collect(SAMVG_POINTS_PER_BATCH) + return [_restore_mask(mask, original_size) for mask in collected] def _components( @@ -432,28 +644,33 @@ def _components( holes before tracing matches AMG's small-region cleanup and prevents a noisy mask from becoming hundreds of even-odd SVG contours. """ - labels, count = _label(mask) + foreground = np.asarray(mask, dtype=bool) + if int(foreground.sum()) < min_pixels: + return [] + height, width = foreground.shape components = [] - for index in range(1, count + 1): - component = labels == index - if int(component.sum()) < min_pixels: + for runs in _run_components(foreground): + if sum(end - start for _y, start, end in runs) < min_pixels: continue + component = np.zeros((height, width), dtype=bool) + for y, start, end in runs: + component[y, start:end] = True if fill_holes: # AMG's postprocessing removes *small* enclosed holes, rather # than turning meaningful cutouts such as an eye into a solid # region. The same area cutoff as tiny components keeps those # two decisions consistent. - background, hole_count = _label(~component) - for hole in range(1, hole_count + 1): - points = background == hole - if ( - int(points.sum()) <= min_pixels - and not points[0].any() - and not points[-1].any() - and not points[:, 0].any() - and not points[:, -1].any() - ): - component[points] = True + for hole in _run_components(~component): + area = sum(end - start for _y, start, end in hole) + if area > min_pixels: + continue + touches_border = any( + y in {0, height - 1} or start == 0 or end == width + for y, start, end in hole + ) + if not touches_border: + for y, start, end in hole: + component[y, start:end] = True components.append(np.asarray(component, dtype=bool)) return components @@ -499,13 +716,20 @@ def recolour_visible_layers( return list(reversed(revised)) -def _impact_error( +def _impact_error_map( target: np.ndarray, canvas: np.ndarray, coverage: np.ndarray -) -> float: +) -> np.ndarray: """SAMVG's blank-canvas error, charging uncovered pixels maximally.""" error = ((target.astype(np.float32) - canvas.astype(np.float32)) / 255.0) ** 2 error[~coverage] = 1.0 - return float(error.mean()) + return error + + +def _impact_error( + target: np.ndarray, canvas: np.ndarray, coverage: np.ndarray +) -> float: + """Return the scalar blank-canvas reconstruction error.""" + return float(_impact_error_map(target, canvas, coverage).mean()) def filter_by_impact( @@ -538,7 +762,9 @@ def filter_by_impact( if initial_coverage.shape != coverage.shape: raise ValueError("initial coverage does not match the target size") coverage = initial_coverage.astype(bool, copy=True) - error = _impact_error(target, canvas, coverage) + error_map = _impact_error_map(target, canvas, coverage) + error_total = float(error_map.sum(dtype=np.float64)) + error = error_total / error_map.size initial_count = len(accepted) candidates = [ component @@ -556,15 +782,22 @@ def filter_by_impact( tuple[int, int, int], tuple(int(value) for value in np.rint(target[mask].mean(axis=0))), ) - next_canvas = canvas.copy() - next_coverage = coverage | mask - next_canvas[mask] = colour - next_error = _impact_error(target, next_canvas, next_coverage) + old_error = error_map[mask] + next_error_values = ( + (target[mask].astype(np.float32) - np.asarray(colour, dtype=np.float32)) + / 255.0 + ) ** 2 + next_error_total = error_total - float(old_error.sum(dtype=np.float64)) + next_error_total += float(next_error_values.sum(dtype=np.float64)) + next_error = next_error_total / error_map.size impact = error - next_error if impact < min_impact: continue accepted.append(MaskLayer(mask, colour, impact)) - canvas, coverage, error = next_canvas, next_coverage, next_error + canvas[mask] = colour + coverage |= mask + error_map[mask] = next_error_values + error_total, error = next_error_total, next_error # Each SAMVG stage is allowed its own retained-mask budget. Applying # this to the combined existing+new list silently limited recovery to # one path once the automatic stage had filled its budget. @@ -598,7 +831,11 @@ def coverage_prompt_points( def prompted_masks( - image: Image.Image, points: list[tuple[int, int]] + image: Image.Image, + points: list[tuple[int, int]], + *, + max_side: int | None = SAMVG_MAX_SIDE, + _runtime: _SamRuntime | None = None, ) -> list[np.ndarray]: """Prompt SAM at centres and return all three masks per point. @@ -609,32 +846,47 @@ def prompted_masks( if not points: return [] import torch - from transformers import SamModel, SamProcessor - + from transformers import SamProcessor + + original_size = image.size + image, scale = _sam_image(image, max_side) + if scale != 1.0: + points = [(round(x * scale), round(y * scale)) for x, y in points] + own_runtime = _runtime is None + runtime = _runtime or _sam_runtime() device = "cuda" if torch.cuda.is_available() else "cpu" log.info("SAMVG prompted masks: using %s.", device) - processor = SamProcessor.from_pretrained(SAMVG_MODEL) - model = SamModel.from_pretrained(SAMVG_MODEL).to(device) + if runtime.processor is None: + runtime.processor = SamProcessor(runtime.generator.image_processor) try: input_points = [[[list(point)] for point in points]] - inputs = processor( + inputs = runtime.processor( images=image, input_points=input_points, return_tensors="pt" ).to(device) - with torch.inference_mode(): - output = model(**inputs) - post = processor.image_processor.post_process_masks( + if ( + runtime.embedding_size == image.size + and runtime.image_embeddings is not None + ): + # The full-image automatic pass has already encoded these pixels. + # Retain only decoder inputs for the coverage/residual prompts. + inputs.pop("pixel_values") + inputs["image_embeddings"] = runtime.image_embeddings + with torch.inference_mode(), _sam_autocast(): + output = runtime.generator.model(**inputs) + post = runtime.processor.image_processor.post_process_masks( output.pred_masks.detach().cpu(), inputs["original_sizes"].detach().cpu(), inputs["reshaped_input_sizes"].detach().cpu(), )[0] return [ - np.asarray(post[prompt, candidate], dtype=bool) + _restore_mask( + np.asarray(post[prompt, candidate], dtype=bool), original_size + ) for prompt in range(post.shape[0]) for candidate in range(post.shape[1]) ] finally: - del model - if torch.cuda.is_available(): + if own_runtime and torch.cuda.is_available(): torch.cuda.empty_cache() @@ -646,10 +898,17 @@ def retrieve_layers( min_impact: float = 1e-5, max_layers: int = 512, fill_holes: bool = True, + max_side: int | None = SAMVG_MAX_SIDE, + _runtime: _SamRuntime | None = None, ) -> list[MaskLayer]: """Run SAMVG's automatic-mask, coverage-prompt, filter sequence.""" image = image.convert("RGB") - initial = automatic_masks(image) if masks is None else masks + runtime = _runtime + if masks is None: + runtime = runtime or _sam_runtime() + initial = automatic_masks(image, max_side=max_side, _runtime=runtime) + else: + initial = masks layers = filter_by_impact( image, initial, @@ -660,7 +919,7 @@ def retrieve_layers( ) layers = recolour_visible_layers(image, layers) points = coverage_prompt_points(layers, (image.height, image.width)) - prompted = prompted_masks(image, points) + prompted = prompted_masks(image, points, max_side=max_side, _runtime=runtime) recovered = filter_by_impact( image, prompted, @@ -847,6 +1106,179 @@ def mask_path( return " ".join(parts) or None +_SKELETON_NEIGHBOURS = ( + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), +) + + +def _thin_mask(mask: np.ndarray) -> np.ndarray: + """Zhang--Suen thinning without adding a SciPy/skimage dependency.""" + thin = np.pad(mask.astype(np.uint8), 1).copy() + changed = True + while changed: + changed = False + for phase in range(2): + remove: list[tuple[int, int]] = [] + for y, x in zip(*np.nonzero(thin), strict=True): + if y in {0, thin.shape[0] - 1} or x in {0, thin.shape[1] - 1}: + continue + ring = [ + thin[y - 1, x], + thin[y - 1, x + 1], + thin[y, x + 1], + thin[y + 1, x + 1], + thin[y + 1, x], + thin[y + 1, x - 1], + thin[y, x - 1], + thin[y - 1, x - 1], + ] + count = sum(ring) + transitions = sum( + left == 0 and right == 1 + for left, right in zip(ring, [*ring[1:], ring[0]], strict=True) + ) + if not (2 <= count <= 6 and transitions == 1): + continue + north, east, south, west = ring[0], ring[2], ring[4], ring[6] + blocked = ( + (north and east and south) or (east and south and west) + if phase == 0 + else (north and east and west) or (north and south and west) + ) + if not blocked: + remove.append((y, x)) + if remove: + changed = True + for y, x in remove: + thin[y, x] = 0 + return thin[1:-1, 1:-1].astype(bool) + + +def _skeleton_traces(mask: np.ndarray) -> list[np.ndarray]: + """Split a thinned medial-axis graph into its endpoint/junction traces.""" + points = {tuple(point) for point in np.argwhere(_thin_mask(mask))} + if len(points) < 2: + return [] + + def adjacent(point: tuple[int, int]) -> list[tuple[int, int]]: + y, x = point + output = [] + for dy, dx in _SKELETON_NEIGHBOURS: + candidate = y + dy, x + dx + if candidate not in points: + continue + # A diagonal across an orthogonal staircase is not another graph + # edge. Keeping it creates artificial triangles and turns every + # curved pixel line into a forest of tiny branches. + if dy and dx and ((y + dy, x) in points or (y, x + dx) in points): + continue + output.append(candidate) + return output + + nodes = {point for point in points if len(adjacent(point)) != 2} + # Closed loops are better represented by SAMVG's filled path: an open + # stroke would introduce caps and a stroke-only loop has no stable start. + if not nodes: + return [] + traversed: set[tuple[tuple[int, int], tuple[int, int]]] = set() + + def edge_key( + first: tuple[int, int], second: tuple[int, int] + ) -> tuple[tuple[int, int], tuple[int, int]]: + return (first, second) if first <= second else (second, first) + + traces: list[np.ndarray] = [] + for node in nodes: + for neighbour in adjacent(node): + edge = edge_key(node, neighbour) + if edge in traversed: + continue + trace, previous, current = [node], node, neighbour + traversed.add(edge) + while current not in nodes: + trace.append(current) + choices = [point for point in adjacent(current) if point != previous] + if len(choices) != 1: + trace = [] + break + previous, current = current, choices[0] + traversed.add(edge_key(previous, current)) + if trace: + trace.append(current) + if len(trace) >= 2: + traces.append( + np.asarray([(x, y) for y, x in trace], dtype=np.float64) + ) + return traces + + +def _trace_path_data(trace: np.ndarray, segments: int) -> str: + """Fit multiple cubic sections to a skeleton rather than one global PCA line.""" + count = max(1, min(segments, math.ceil((len(trace) - 1) / 8))) + boundaries = np.linspace(0, len(trace) - 1, count + 1, dtype=int) + output = [f"M {trace[0, 0]:.2f} {trace[0, 1]:.2f}"] + for first, last in itertools.pairwise(boundaries): + sample = trace[first : last + 1] + if len(sample) == 2: + output.append(f"L {sample[-1, 0]:.2f} {sample[-1, 1]:.2f}") + else: + control_a, control_b = _fit_cubic(sample) + end = sample[-1] + output.append( + f"C {control_a[0]:.2f} {control_a[1]:.2f} " + f"{control_b[0]:.2f} {control_b[1]:.2f} {end[0]:.2f} {end[1]:.2f}" + ) + return " ".join(output) + + +def _mask_distance(mask: np.ndarray) -> np.ndarray: + """Two-pass chamfer distance to the background in mask-pixel units.""" + distance = np.where(mask, np.inf, 0.0).astype(np.float64) + diagonal = math.sqrt(2.0) + for y in range(distance.shape[0]): + for x in range(distance.shape[1]): + if not mask[y, x]: + continue + candidates = [] + if y: + candidates.append(distance[y - 1, x] + 1) + if x: + candidates.append(distance[y - 1, x - 1] + diagonal) + if x + 1 < distance.shape[1]: + candidates.append(distance[y - 1, x + 1] + diagonal) + if x: + candidates.append(distance[y, x - 1] + 1) + distance[y, x] = min(candidates, default=distance[y, x]) + for y in range(distance.shape[0] - 1, -1, -1): + for x in range(distance.shape[1] - 1, -1, -1): + if not mask[y, x]: + continue + candidates = [distance[y, x]] + if y + 1 < distance.shape[0]: + candidates.append(distance[y + 1, x] + 1) + if x: + candidates.append(distance[y + 1, x - 1] + diagonal) + if x + 1 < distance.shape[1]: + candidates.append(distance[y + 1, x + 1] + diagonal) + if x + 1 < distance.shape[1]: + candidates.append(distance[y, x + 1] + 1) + distance[y, x] = min(candidates) + return distance + + +def _trace_sections(trace: np.ndarray, segments: int) -> list[np.ndarray]: + count = max(1, min(segments, math.ceil((len(trace) - 1) / 8))) + boundaries = np.linspace(0, len(trace) - 1, count + 1, dtype=int) + return [trace[first : last + 1] for first, last in itertools.pairwise(boundaries)] + + def mask_stroke( mask: np.ndarray, *, segments: int = 8, overlap_pixels: int = 0 ) -> tuple[str, float] | None: @@ -859,75 +1291,92 @@ def mask_stroke( """ if overlap_pixels: mask = _binary_dilation(mask, overlap_pixels) - ys, xs = np.nonzero(mask) + _ys, xs = np.nonzero(mask) if len(xs) < 8: return None - min_x, max_x = int(xs.min()), int(xs.max()) - min_y, max_y = int(ys.min()), int(ys.max()) - width, height = max_x - min_x + 1, max_y - min_y + 1 - major, minor = max(width, height), min(width, height) - if minor == 0 or major < 12 or major / minor < 3: - return None - # A component's area divided by its long span is its average orthogonal - # width. This rejects narrow-looking leaves and regions with broad ends. - estimated_width = len(xs) / major - if estimated_width > min(8.0, major * 0.18): - return None # A hole is topology that a single centreline cannot preserve. if len(_loops(mask)) != 1: return None - points = np.column_stack((xs, ys)).astype(np.float64) - centre = points.mean(axis=0) - _values, vectors = np.linalg.eigh(np.cov((points - centre).T)) - direction = vectors[:, -1] - projection = (points - centre) @ direction - bin_count = min(64, max(4, segments * 4)) - bins = np.linspace(projection.min(), projection.max(), bin_count + 1) - line = [] - for start, end in itertools.pairwise(bins): - selected = points[(projection >= start) & (projection <= end)] - if len(selected): - line.append(selected.mean(axis=0)) - if len(line) < 2: + traces = _skeleton_traces(mask) + if len(traces) != 1: return None - trace = np.asarray(line) - if len(trace) == 2: - data = ( - f"M {trace[0, 0]:.2f} {trace[0, 1]:.2f} " - f"L {trace[1, 0]:.2f} {trace[1, 1]:.2f}" - ) - else: - control_a, control_b = _fit_cubic(trace) - data = ( - f"M {trace[0, 0]:.2f} {trace[0, 1]:.2f} C " - f"{control_a[0]:.2f} {control_a[1]:.2f} " - f"{control_b[0]:.2f} {control_b[1]:.2f} " - f"{trace[-1, 0]:.2f} {trace[-1, 1]:.2f}" - ) - return data, max(1.0, float(estimated_width)) + trace = traces[0] + length = float(np.linalg.norm(np.diff(trace, axis=0), axis=1).sum()) + # Arc length, rather than a bounding-box axis, preserves strongly curved + # thin components whose width and height are similar. + estimated_width = len(xs) / max(length, 1.0) + if length < 12 or estimated_width > min(8.0, length * 0.3): + return None + distance = _mask_distance(mask) + widths = [2 * (distance[int(y), int(x)] - 0.5) for x, y in trace] + data = _trace_path_data(trace, segments) + return data, max(1.0, float(np.median(widths))) -def _layer_svg_attributes(layer: MaskLayer, segments: int) -> dict[str, str] | None: - """Choose the fill or stroke primitive appropriate for one SAM mask.""" +def mask_strokes( + mask: np.ndarray, *, segments: int = 8, overlap_pixels: int = 0 +) -> list[tuple[str, float]]: + """Trace a thin component into independently editable constant-width paths. + + A branch becomes one path per medial-axis edge. Each long edge is divided + into cubic sections and each section gets its local median width, giving an + SVG approximation of a variable-width centreline without nonstandard SVG + extensions. Round caps and joins make the adjacent sections continuous. + """ + if overlap_pixels: + mask = _binary_dilation(mask, overlap_pixels) + _ys, xs = np.nonzero(mask) + if len(xs) < 8: + return [] + if len(_loops(mask)) != 1: + return [] + traces = _skeleton_traces(mask) + length = sum( + float(np.linalg.norm(np.diff(trace, axis=0), axis=1).sum()) for trace in traces + ) + estimated_width = len(xs) / max(length, 1.0) + if length < 12 or estimated_width > min(8.0, length * 0.3): + return [] + distance = _mask_distance(mask) + output: list[tuple[str, float]] = [] + for trace in traces: + for section in _trace_sections(trace, segments): + if len(section) < 2: + continue + widths = [2 * (distance[int(y), int(x)] - 0.5) for x, y in section] + output.append( + (_trace_path_data(section, 1), max(1.0, float(np.median(widths)))) + ) + return output + + +def _layer_svg_attributes( + layer: MaskLayer, segments: int, *, hybrid_strokes: bool = True +) -> list[dict[str, str]]: + """Trace one SAM mask, using optional strokes only outside the thesis mode.""" colour = f"#{layer.colour[0]:02x}{layer.colour[1]:02x}{layer.colour[2]:02x}" - stroke = mask_stroke( - layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels + strokes = ( + mask_strokes(layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels) + if hybrid_strokes + else [] ) - if stroke is not None: - data, width = stroke - return { - "d": data, - "fill": "none", - "stroke": colour, - "stroke-width": f"{width:.2f}", - "stroke-linecap": "round", - "stroke-linejoin": "round", - } + if strokes: + return [ + { + "d": data, + "fill": "none", + "stroke": colour, + "stroke-width": f"{width:.2f}", + "stroke-linecap": "round", + "stroke-linejoin": "round", + } + for data, width in strokes + ] data = mask_path(layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels) if data is None: - return None - return {"d": data, "fill": colour, "fill-rule": "evenodd"} + return [] + return [{"d": data, "fill": colour, "fill-rule": "evenodd"}] def generate_svg( @@ -939,7 +1388,9 @@ def generate_svg( max_layers: int = 512, segments: int = 16, fill_holes: bool = True, + hybrid_strokes: bool = True, ocr: bool = True, + max_side: int | None = SAMVG_MAX_SIDE, rasterize: Callable[[str, int, int], bytes] | None = None, ) -> str: """Generate SAMVG's traced, pre-optimisation SVG from a target image.""" @@ -960,12 +1411,14 @@ def generate_svg( min_impact=min_impact, max_layers=max_layers, fill_holes=fill_holes, + max_side=max_side, ) ) paths = [] for layer in layers: - attributes = _layer_svg_attributes(layer, segments) - if attributes: + for attributes in _layer_svg_attributes( + layer, segments, hybrid_strokes=hybrid_strokes + ): markup = " ".join(f'{key}="{value}"' for key, value in attributes.items()) paths.append(f"") width, height = image.size @@ -1018,18 +1471,24 @@ def residual_prompt_points( return [(x, y) for _score, x, y in sorted(points, reverse=True)[:max_points]] -def _append_layers(svg: str, layers: list[MaskLayer], segments: int) -> str: +def _append_layers( + svg: str, + layers: list[MaskLayer], + segments: int, + *, + hybrid_strokes: bool = True, +) -> str: """Add newly prompted paths to an already optimised SVG.""" root = ET.fromstring(svg) for layer in layers: - attributes = _layer_svg_attributes(layer, segments) - if attributes is None: - continue - ET.SubElement( - root, - "{http://www.w3.org/2000/svg}path", - attributes, - ) + for attributes in _layer_svg_attributes( + layer, segments, hybrid_strokes=hybrid_strokes + ): + ET.SubElement( + root, + "{http://www.w3.org/2000/svg}path", + attributes, + ) return ET.tostring(root, encoding="unicode") @@ -1100,10 +1559,10 @@ def _accepted_fit( svg: str, image: Image.Image, *, rasterize, steps: int ) -> tuple[str, Image.Image]: """Keep a differentiable fit only when the actual SVG renderer improves.""" - from vectrify.refine.paths import fit_filled_svg + from vectrify.refine.paths import fit_filled_svg_bounded before = _render_svg(svg, image, rasterize) - fitted = fit_filled_svg(svg, image, steps=steps) + fitted = fit_filled_svg_bounded(svg, image, rasterize=rasterize, steps=steps) after = _render_svg(fitted, image, rasterize) if _mse(image, after) <= _mse(image, before): return fitted, after @@ -1119,7 +1578,8 @@ def vectorize_svg( min_pixels: int = 32, min_impact: float = 1e-5, max_layers: int = 512, - segments: int = 8, + segments: int = 16, + max_side: int | None = SAMVG_MAX_SIDE, ) -> str: """Run SAMVG's two 500-step optimise-and-recover phases. @@ -1128,38 +1588,56 @@ def vectorize_svg( built-in filled-path optimiser so SAMVG has no external renderer dependency. """ image = image.convert("RGB") - layers = retrieve_layers( - image, - min_pixels=min_pixels, - min_impact=min_impact, - max_layers=max_layers, - ) - initial = _append_layers( - f'', - layers, - segments, - ) - first, first_render = _accepted_fit( - initial, image, rasterize=rasterize, steps=steps - ) - points = residual_prompt_points(image, first_render) - _canvas, coverage = _render_layers((image.height, image.width), layers) - added = filter_by_impact( - image, - prompted_masks(image, points), - existing=layers, - initial_canvas=np.asarray(first_render, dtype=np.uint8), - initial_coverage=coverage, - min_pixels=min_pixels, - min_impact=min_impact, - max_layers=max_layers, - )[len(layers) :] - log.info( - "SAMVG residual pass: %d prompt(s), %d accepted added path(s).", - len(points), - len(added), - ) - return _accepted_fit( - _append_layers(first, added, segments), image, rasterize=rasterize, steps=steps - )[0] + runtime = _sam_runtime() + try: + layers = retrieve_layers( + image, + min_pixels=min_pixels, + min_impact=min_impact, + max_layers=max_layers, + max_side=max_side, + _runtime=runtime, + ) + initial = _append_layers( + f'', + layers, + segments, + hybrid_strokes=False, + ) + first, first_render = _accepted_fit( + initial, image, rasterize=rasterize, steps=steps + ) + points = residual_prompt_points(image, first_render) + _canvas, coverage = _render_layers((image.height, image.width), layers) + added = filter_by_impact( + image, + prompted_masks(image, points, max_side=max_side, _runtime=runtime), + existing=layers, + initial_canvas=np.asarray(first_render, dtype=np.uint8), + initial_coverage=coverage, + min_pixels=min_pixels, + min_impact=min_impact, + max_layers=max_layers, + )[len(layers) :] + log.info( + "SAMVG residual pass: %d prompt(s), %d accepted added path(s).", + len(points), + len(added), + ) + return _accepted_fit( + _append_layers(first, added, segments, hybrid_strokes=False), + image, + rasterize=rasterize, + steps=steps, + )[0] + finally: + del runtime + try: + import torch + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except ImportError: # pragma: no cover - installation-specific + pass diff --git a/tests/refine/test_fidelity.py b/tests/refine/test_fidelity.py index e17e96c5..9c45648d 100644 --- a/tests/refine/test_fidelity.py +++ b/tests/refine/test_fidelity.py @@ -138,7 +138,7 @@ def test_a_pinned_vertex_does_not_move(): ).convert("L") path_d = "M 300 300 C 340 280 400 280 440 300" knots = to_knots(parse_cubics(path_d)) - fitted, _first, _last = fit_group( + fitted, _widths, _colours, _first, _last = fit_group( [path_d], [3.5], target, target, steps=6, pinned={0} ) moved = to_knots(parse_cubics(fitted[0])) diff --git a/tests/refine/test_filled_paths.py b/tests/refine/test_filled_paths.py index a9c5889d..9428fcda 100644 --- a/tests/refine/test_filled_paths.py +++ b/tests/refine/test_filled_paths.py @@ -1,4 +1,5 @@ import io +import xml.etree.ElementTree as ET import numpy as np import pytest @@ -17,6 +18,7 @@ _tiled_large_path_coverage, _xing_loss, fit_filled_svg, + fit_opaque_fills_locally, parse_filled_cubics, ) @@ -127,9 +129,11 @@ def test_native_even_odd_coverage_stays_cairo_validated(): torch.tensor(contour, dtype=torch.float32, device="cuda") for contour in parse_filled_cubics(DONUT_PATH) ] - native = _fill_path_coverage( - contours, (0, 0, size, size), fill_rule="evenodd" - ).cpu().numpy() + native = ( + _fill_path_coverage(contours, (0, 0, size, size), fill_rule="evenodd") + .cpu() + .numpy() + ) head = f'' blank = f"{head}" drawn = f'{head}' @@ -167,9 +171,7 @@ def test_native_analytic_cubic_coverage_stays_cairo_validated(): dtype=torch.float32, device="cuda", ) - contour = torch.cat((contour, contour[:1].expand(16 - len(contour), -1, -1)))[ - None - ] + contour = torch.cat((contour, contour[:1].expand(16 - len(contour), -1, -1)))[None] native = _fill_coverages(contour, (0, 0, size, size), subpixels=4).cpu().numpy() head = f'' blank = f"{head}" @@ -207,9 +209,7 @@ def test_native_analytic_multi_contour_coverage_preserves_a_hole(): ] controls = torch.cat( [ - torch.cat((contour, contour[:1].expand(16 - len(contour), -1, -1)))[ - None - ] + torch.cat((contour, contour[:1].expand(16 - len(contour), -1, -1)))[None] for contour in contours ] ).requires_grad_() @@ -222,6 +222,7 @@ def test_native_analytic_multi_contour_coverage_preserves_a_hole(): assert controls.grad is not None assert controls.grad.abs().sum() > 0 + SVG = ( '' '' + ) + source = ( + '' + + "".join(paths) + + "" + ) + target = Image.new("RGB", (100, 80), "black") + reference = io.BytesIO() + target.save(reference, format="PNG") + + fitted = fit_opaque_fills_locally( + source, + reference.getvalue(), + steps=1, + maximum_paths=4, + rasterize=lambda markup, width, height: rasterize_svg_to_png_bytes( + markup, out_w=width, out_h=height + ), + ) + + before = [ + element.get("fill") + for element in ET.fromstring(source).iter() + if element.get("d") + ] + after = [ + element.get("fill") + for element in ET.fromstring(fitted).iter() + if element.get("d") + ] + assert len(before) == len(after) == 20 + assert sum(left != right for left, right in zip(before, after, strict=True)) <= 4 + + DONUT_PATH = ( "M 12 48 C 12 5 84 5 84 48 C 84 91 12 91 12 48 Z " "M 34 48 C 34 30 62 30 62 48 C 62 66 34 66 34 48 Z" @@ -564,9 +607,7 @@ def test_filled_fit_uses_analytic_tiles_for_large_cuda_paths(monkeypatch): for row in range(2): for column in range(8): x, y = 2 + column * 7, 8 + row * 24 - pieces.append( - f"M {x} {y} L {x + 5} {y} L {x + 5} {y + 5} L {x} {y + 5} Z" - ) + pieces.append(f"M {x} {y} L {x + 5} {y} L {x + 5} {y + 5} L {x} {y + 5} Z") svg = ( '' f'' diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 18a9c236..fea618b7 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -1,3 +1,4 @@ +import io import sys import xml.etree.ElementTree as ET from contextlib import nullcontext @@ -6,7 +7,10 @@ import numpy as np from PIL import Image +import vectrify.refine.paths as paths import vectrify.refine.samvg as samvg +from vectrify.formats.svg.plugin import SvgPlugin +from vectrify.refine.paths import fit_svg_primitives_locally from vectrify.refine.samvg import ( MaskLayer, TextLayer, @@ -24,6 +28,7 @@ generate_svg, mask_path, mask_stroke, + mask_strokes, recolour_visible_layers, residual_prompt_points, ) @@ -96,6 +101,73 @@ def generate(self, **kwargs): assert _text_svg_attributes(layers[0])["font-family"] == "sans-serif" +def test_accepted_fit_uses_bounded_fill_coordinate_descent(monkeypatch): + seen = {} + + def bounded(svg, image, *, rasterize, steps): + seen["image"] = image.size + seen["steps"] = steps + assert rasterize is not None + return svg + + monkeypatch.setattr(paths, "fit_filled_svg_bounded", bounded) + image = Image.new("RGB", (16, 16), "white") + svg = '' + plugin = SvgPlugin() + + fitted, rendered = samvg._accepted_fit( + svg, image, rasterize=plugin.rasterize, steps=7 + ) + + assert fitted == svg + assert rendered.size == image.size + assert seen == {"image": (16, 16), "steps": 7} + + +def test_vectorize_svg_runs_a_second_residual_recovery_phase(monkeypatch): + image = Image.new("RGB", (16, 16), "white") + base = np.zeros((16, 16), dtype=bool) + base[2:10, 2:10] = True + added = np.zeros((16, 16), dtype=bool) + added[10:14, 10:14] = True + initial_layer = MaskLayer(base, (10, 20, 30), 1.0) + added_layer = MaskLayer(added, (40, 50, 60), 1.0) + calls = [] + monkeypatch.setattr(samvg, "_sam_runtime", lambda: object()) + monkeypatch.setattr( + samvg, "retrieve_layers", lambda *_args, **_kwargs: [initial_layer] + ) + monkeypatch.setattr(samvg, "residual_prompt_points", lambda *_args: [(12, 12)]) + monkeypatch.setattr(samvg, "prompted_masks", lambda *_args, **_kwargs: [added]) + monkeypatch.setattr( + samvg, + "filter_by_impact", + lambda _image, _masks, **kwargs: [*kwargs["existing"], added_layer], + ) + + def accepted(svg, _image, *, rasterize, steps): + assert rasterize is not None + calls.append( + ( + sum( + element.tag.split("}")[-1] == "path" + for element in ET.fromstring(svg).iter() + ), + steps, + ) + ) + return svg, image + + monkeypatch.setattr(samvg, "_accepted_fit", accepted) + result = samvg.vectorize_svg(image, rasterize=SvgPlugin().rasterize, steps=3) + + assert calls == [(1, 3), (2, 3)] + root = ET.fromstring(result) + paths = list(root.findall("{http://www.w3.org/2000/svg}path")) + assert len(paths) == 2 + assert all(path.get("stroke") is None for path in paths) + + def test_generate_svg_writes_detected_words_as_editable_text(monkeypatch): monkeypatch.setattr(samvg, "retrieve_layers", lambda *_args, **_kwargs: []) monkeypatch.setattr( @@ -169,8 +241,8 @@ def test_automatic_masks_uses_source_sized_first_layer_crops(monkeypatch): class Generator: device = "cuda:0" - def __call__(self, source, **_kwargs): - calls.append(source.size) + def __call__(self, source, **kwargs): + calls.append((source.size, kwargs)) return {"masks": [Image.new("1", source.size, 1)]} monkeypatch.setitem( @@ -181,12 +253,42 @@ def __call__(self, source, **_kwargs): masks = automatic_masks(Image.new("RGB", (12, 8))) - assert calls[0] == (12, 8) - assert sorted(calls[1:]) == [(7, 5)] * 4 + assert calls[0][0] == (12, 8) + assert sorted(size for size, _kwargs in calls[1:]) == [(7, 5)] * 4 + assert all( + kwargs["points_per_batch"] == samvg.SAMVG_POINTS_PER_BATCH + and kwargs["points_per_crop"] == 32 + and kwargs["crops_n_layers"] == 0 + for _size, kwargs in calls + ) assert len(masks) == 5 assert all(mask.shape == (8, 12) for mask in masks) +def test_retrieve_layers_reuses_one_runtime_for_automatic_and_coverage_prompts( + monkeypatch, +): + runtime = samvg._SamRuntime(generator=object()) + seen = {} + monkeypatch.setattr( + samvg, + "automatic_masks", + lambda _image, **kwargs: seen.setdefault("automatic", kwargs) or [], + ) + monkeypatch.setattr(samvg, "filter_by_impact", lambda *_args, **_kwargs: []) + monkeypatch.setattr(samvg, "coverage_prompt_points", lambda *_args: [(2, 3)]) + monkeypatch.setattr( + samvg, + "prompted_masks", + lambda _image, _points, **kwargs: seen.setdefault("prompted", kwargs) or [], + ) + + samvg.retrieve_layers(Image.new("RGB", (8, 8)), _runtime=runtime) + + assert seen["automatic"]["_runtime"] is runtime + assert seen["prompted"]["_runtime"] is runtime + + def test_filter_by_impact_keeps_useful_nested_masks_in_layer_order(): image = Image.new("RGB", (12, 12), "white") pixels = np.asarray(image).copy() @@ -205,6 +307,47 @@ def test_filter_by_impact_keeps_useful_nested_masks_in_layer_order(): assert all(layer.impact > 0 for layer in layers) +def test_incremental_impact_scoring_matches_full_canvas_recomputation(): + pixels = np.full((16, 16, 3), 255, dtype=np.uint8) + pixels[2:12, 2:12] = (180, 60, 30) + pixels[5:14, 5:14] = (30, 140, 220) + image = Image.fromarray(pixels) + first = np.zeros((16, 16), dtype=bool) + first[2:12, 2:12] = True + second = np.zeros((16, 16), dtype=bool) + second[5:14, 5:14] = True + third = np.zeros((16, 16), dtype=bool) + third[7:9, 7:9] = True + + target = np.asarray(image, dtype=np.uint8) + canvas = np.zeros_like(target) + coverage = np.zeros(target.shape[:2], dtype=bool) + error = samvg._impact_error(target, canvas, coverage) + expected = [] + for mask in sorted( + [first, second, third], key=lambda item: int(item.sum()), reverse=True + ): + colour = tuple(int(value) for value in np.rint(target[mask].mean(axis=0))) + next_canvas = canvas.copy() + next_coverage = coverage | mask + next_canvas[mask] = colour + next_error = samvg._impact_error(target, next_canvas, next_coverage) + impact = error - next_error + if impact >= 1e-5: + expected.append((mask, colour, impact)) + canvas, coverage, error = next_canvas, next_coverage, next_error + + actual = filter_by_impact( + image, [first, second, third], min_pixels=1, min_impact=1e-5 + ) + + assert len(actual) == len(expected) + for layer, (mask, colour, impact) in zip(actual, expected, strict=True): + assert np.array_equal(layer.mask, mask) + assert layer.colour == colour + assert np.isclose(layer.impact, impact) + + def test_recolour_uses_only_each_layers_visible_pixels(): image = Image.new("RGB", (8, 8), (220, 30, 30)) pixels = np.asarray(image).copy() @@ -315,7 +458,9 @@ def test_thin_single_contour_mask_is_emitted_as_a_round_stroke(): mask[4:28, 5:8] = True stroke = mask_stroke(mask) - svg = generate_svg(image, [mask], min_pixels=1, min_impact=0.00001) + svg = generate_svg( + image, [mask], min_pixels=1, min_impact=0.00001, hybrid_strokes=True + ) path = ET.fromstring(svg).find("{http://www.w3.org/2000/svg}path") assert stroke is not None @@ -326,6 +471,47 @@ def test_thin_single_contour_mask_is_emitted_as_a_round_stroke(): assert " Z" not in path.get("d", "") +def test_curved_thin_mask_uses_a_multisegment_skeleton_stroke(): + mask = np.zeros((48, 48), dtype=bool) + for x in range(6, 42): + y = round(24 + 10 * np.sin((x - 6) / 35 * np.pi)) + mask[y - 1 : y + 2, x] = True + + stroke = mask_stroke(mask, segments=8) + + assert stroke is not None + assert stroke[0].count("C ") >= 2 + + +def test_thin_branch_mask_emits_independent_width_aware_strokes(): + mask = np.zeros((48, 48), dtype=bool) + mask[6:42, 22:25] = True + mask[6:9, 10:37] = True + + strokes = mask_strokes(mask, segments=8) + + assert len(strokes) >= 3 + assert all(data.startswith("M ") and width >= 1 for data, width in strokes) + + +def test_branched_stroke_seed_roundtrips_through_unified_local_fitter(): + mask = np.zeros((48, 48), dtype=bool) + mask[6:42, 22:25] = True + mask[6:9, 10:37] = True + image = Image.new("RGB", (48, 48), "white") + svg = generate_svg(image, [mask], min_pixels=1, min_impact=0, ocr=False) + plugin = SvgPlugin() + reference = plugin.rasterize(svg, 48, 48) + + fitted = fit_svg_primitives_locally( + svg, reference, rasterize=plugin.rasterize, steps=1 + ) + + assert fitted != svg + assert fitted.count("stroke-width=") >= 3 + Image.open(io.BytesIO(plugin.rasterize(fitted, 48, 48))).verify() + + def test_coverage_prompt_points_selects_the_centre_of_a_large_empty_region(): occupied = np.zeros((32, 32), dtype=bool) occupied[:, :12] = True @@ -381,3 +567,49 @@ def test_cubic_fit_reparameterises_nonuniform_curve_samples(): assert np.linalg.norm(np.hstack(refined) - np.hstack((expected_a, expected_b))) < ( np.linalg.norm(np.hstack(uniform) - np.hstack((expected_a, expected_b))) ) + + +def test_sam_input_cap_restores_binary_masks_to_the_original_canvas(): + image = Image.new("RGB", (100, 50), "white") + + capped, scale = samvg._sam_image(image, 32) + restored = samvg._restore_mask( + np.ones((capped.height, capped.width), dtype=bool), image.size + ) + + assert capped.size == (32, 16) + assert scale == 0.32 + assert restored.shape == (50, 100) + assert restored.all() + + +def test_generate_svg_forwards_the_optional_sam_size_cap(monkeypatch): + seen = {} + + def retrieve(_image, **kwargs): + seen["max_side"] = kwargs["max_side"] + return [] + + monkeypatch.setattr( + samvg, + "retrieve_layers", + retrieve, + ) + + generate_svg(Image.new("RGB", (80, 40)), max_side=32, ocr=False) + + assert seen == {"max_side": 32} + + +def test_generate_svg_defaults_to_sam_native_input_size(monkeypatch): + seen = {} + + def retrieve(_image, **kwargs): + seen["max_side"] = kwargs["max_side"] + return [] + + monkeypatch.setattr(samvg, "retrieve_layers", retrieve) + + generate_svg(Image.new("RGB", (80, 40)), ocr=False) + + assert seen == {"max_side": samvg.SAMVG_MAX_SIDE} From bc58e227f228b4bbf8f9829925836f2f01f9f905 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 23:16:44 +0200 Subject: [PATCH 10/57] perf: reduce SAMVG mask transfer overhead --- src/vectrify/refine/samvg.py | 156 ++++++++++++++++++++++++++++------- 1 file changed, 127 insertions(+), 29 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index fa0b8b39..037d3f9a 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -492,47 +492,140 @@ def _sam_autocast(): def _automatic_forward(inputs: Any, runtime: _SamRuntime) -> dict[str, Any]: - """Decode on CUDA, then expand and filter masks on CPU. + """Decode one prompt batch and retain compact GPU candidates.""" + import torch - The stock Transformers pipeline expands a prompt batch to the original - image size on CUDA. At 1024px that transient allocation is larger than the - decoder itself. Its filtering sequence is unchanged here; only the - post-decoder device changes. - """ generator = runtime.generator - input_boxes = inputs.pop("input_boxes").detach().cpu().float() + input_boxes = inputs.pop("input_boxes").float() is_last = inputs.pop("is_last") original_sizes = inputs.pop("original_sizes").detach().cpu().tolist() reshaped_sizes = inputs.pop("reshaped_input_sizes", None) if reshaped_sizes is not None: reshaped_sizes = reshaped_sizes.detach().cpu().tolist() - with _sam_autocast(): + # `.cpu()` alone preserves the decoder's autograd graph, retaining every + # prior prompt batch's CUDA activations. AMG is inference-only, so make + # that lifetime explicit before handing compact candidates to the host. + with torch.inference_mode(), _sam_autocast(): model_outputs = generator.model(**inputs) - masks = generator.image_processor.post_process_masks( - model_outputs.pred_masks.detach().cpu(), - original_sizes, - mask_threshold=0, - reshaped_input_sizes=reshaped_sizes, - binarize=False, - ) - filtered_masks, scores, boxes = generator.image_processor.filter_masks( - masks[0], - model_outputs.iou_scores.detach().cpu().float()[0], - original_sizes[0], - input_boxes[0], - SAMVG_PRED_IOU_THRESH, - SAMVG_STABILITY_SCORE_THRESH, - 0, - 1, - ) + masks, scores, boxes = _low_resolution_candidates( + model_outputs.pred_masks, model_outputs.iou_scores + ) return { - "masks": filtered_masks, + "masks": masks, "is_last": is_last, "boxes": boxes, - "iou_scores": scores, + "scores": scores, + "original_size": original_sizes[0], + "reshaped_size": reshaped_sizes[0] if reshaped_sizes is not None else None, + "crop_box": input_boxes[0], } +def _low_resolution_candidates( + pred_masks: Any, iou_scores: Any +) -> tuple[Any, Any, Any]: + """Filter and box decoder masks before full-resolution interpolation.""" + import torch + from transformers.models.sam.image_processing_sam import ( + _batched_mask_to_box, + _compute_stability_score, + ) + + masks = pred_masks.reshape(-1, *pred_masks.shape[-2:]) + scores = iou_scores.reshape(-1) + keep = torch.ones(len(masks), dtype=torch.bool, device=masks.device) + if SAMVG_PRED_IOU_THRESH > 0: + keep &= scores > SAMVG_PRED_IOU_THRESH + if SAMVG_STABILITY_SCORE_THRESH > 0: + stability = _compute_stability_score(masks, 0, 1) + keep &= stability > SAMVG_STABILITY_SCORE_THRESH + masks, scores = masks[keep] > 0, scores[keep] + return masks, scores, _batched_mask_to_box(masks) + + +def _filter_automatic_masks( + masks: Any, + iou_scores: Any, + original_size: list[int], + cropped_box_image: Any, +) -> tuple[Any, Any, Any]: + """Apply AMG's score/edge filter without its lossy RLE round trip.""" + import torch + from transformers.models.sam.image_processing_sam import ( + _batched_mask_to_box, + _compute_stability_score, + _is_box_near_crop_edge, + _pad_masks, + ) + + original_height, original_width = original_size + scores = iou_scores.reshape(-1) + masks = masks.reshape(-1, *masks.shape[-2:]) + keep = torch.ones(len(masks), dtype=torch.bool, device=masks.device) + if SAMVG_PRED_IOU_THRESH > 0: + keep &= scores > SAMVG_PRED_IOU_THRESH + if SAMVG_STABILITY_SCORE_THRESH > 0: + stability = _compute_stability_score(masks, 0, 1) + keep &= stability > SAMVG_STABILITY_SCORE_THRESH + scores, masks = scores[keep], masks[keep] > 0 + boxes = _batched_mask_to_box(masks) + keep = ~_is_box_near_crop_edge( + boxes, cropped_box_image, [0, 0, original_width, original_height] + ) + return ( + _pad_masks(masks[keep], cropped_box_image, original_height, original_width), + scores[keep], + boxes[keep], + ) + + +def _finalize_automatic_masks( + outputs: list[dict[str, Any]], runtime: _SamRuntime +) -> list[np.ndarray]: + """NMS compact candidates, then expand and transfer only survivors.""" + import torch + from torchvision.ops import batched_nms + + masks = [output["masks"] for output in outputs if len(output["masks"])] + if not masks: + return [] + scores = torch.cat([output["scores"] for output in outputs]) + boxes = torch.cat([output["boxes"] for output in outputs]) + keep = batched_nms( + boxes=boxes.float(), + scores=scores.float(), + idxs=torch.zeros(len(boxes), dtype=torch.long), + iou_threshold=0.7, + ) + selected_masks = torch.cat(masks)[keep] + selected_scores = scores[keep] + metadata = outputs[0] + expanded: list[np.ndarray] = [] + # A small final batch bounds GPU interpolation memory. Every survivor is + # still expanded with Transformers' exact bilinear mask post-processing. + for start in range(0, len(selected_masks), 16): + stop = start + 16 + masks_at_size = runtime.generator.image_processor.post_process_masks( + [ + selected_masks[start:stop] + .unsqueeze(1) + .to(runtime.generator.device, dtype=torch.float16) + ], + [metadata["original_size"]], + [metadata["reshaped_size"]], + mask_threshold=0, + binarize=False, + )[0] + filtered, _scores, _boxes = _filter_automatic_masks( + masks_at_size, + selected_scores[start:stop].to(runtime.generator.device), + metadata["original_size"], + metadata["crop_box"], + ) + expanded.extend(np.asarray(mask.cpu(), dtype=bool) for mask in filtered) + return expanded + + def _automatic_masks_for( source: Image.Image, runtime: _SamRuntime, @@ -579,8 +672,13 @@ def _automatic_masks_for( runtime.image_embeddings = embedding runtime.embedding_size = source.size outputs.append(_automatic_forward(inputs, runtime)) - output = generator.postprocess(outputs) - return [np.asarray(mask, dtype=bool) for mask in output["masks"]] + # Keep the GPU bounded to one decoder batch. Binary 256px masks are + # four times smaller than the previous float logits and will undergo + # one global NMS before any full-resolution interpolation. + outputs[-1]["masks"] = outputs[-1]["masks"].cpu() + outputs[-1]["scores"] = outputs[-1]["scores"].cpu() + outputs[-1]["boxes"] = outputs[-1]["boxes"].cpu() + return _finalize_automatic_masks(outputs, runtime) def automatic_masks( From c01b2e7575686f30c4c0ff9c7fb954f11e0ad143 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 09:19:24 +0200 Subject: [PATCH 11/57] chore: ignore generated benchmark results --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a3b109b4..869283fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .idea/ *.egg-info /build/ +/bench/results/ /output /models # Run output: vectrify writes /runs/ next to the output file From 02934ffa70db46428bb9290ce94894b0def0b8c1 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 09:51:16 +0200 Subject: [PATCH 12/57] perf: accelerate SAMVG impact filtering --- src/vectrify/refine/samvg.py | 35 +++++++++++++++++++++++++-------- tests/refine/test_samvg.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 037d3f9a..e6d6dc23 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -750,25 +750,44 @@ def _components( for runs in _run_components(foreground): if sum(end - start for _y, start, end in runs) < min_pixels: continue + min_y = min(y for y, _start, _end in runs) + max_y = max(y for y, _start, _end in runs) + min_x = min(start for _y, start, _end in runs) + max_x = max(end for _y, _start, end in runs) + local = np.zeros((max_y - min_y + 1, max_x - min_x), dtype=bool) + for y, start, end in runs: + local[y - min_y, start - min_x : end - min_x] = True component = np.zeros((height, width), dtype=bool) for y, start, end in runs: component[y, start:end] = True - if fill_holes: + # A hole must contain at least one non-component pixel strictly inside + # this box. Most small SAM fragments are solid or only touch the box + # boundary, so avoid a connected-components pass when a hole is + # impossible. + has_interior_background = ( + local.shape[0] > 2 and local.shape[1] > 2 and not local[1:-1, 1:-1].all() + ) + if fill_holes and has_interior_background: # AMG's postprocessing removes *small* enclosed holes, rather # than turning meaningful cutouts such as an eye into a solid # region. The same area cutoff as tiny components keeps those # two decisions consistent. - for hole in _run_components(~component): + # The exterior background necessarily reaches a component bounding + # box edge, while an enclosed hole cannot. Checking this compact + # box is equivalent to checking the full mask, without scanning a + # 1024px canvas once for every small disconnected component. + local_height, local_width = local.shape + for hole in _run_components(~local): area = sum(end - start for _y, start, end in hole) if area > min_pixels: continue touches_border = any( - y in {0, height - 1} or start == 0 or end == width + y in {0, local_height - 1} or start == 0 or end == local_width for y, start, end in hole ) if not touches_border: for y, start, end in hole: - component[y, start:end] = True + component[y + min_y, start + min_x : end + min_x] = True components.append(np.asarray(component, dtype=bool)) return components @@ -838,7 +857,7 @@ def filter_by_impact( initial_canvas: np.ndarray | None = None, initial_coverage: np.ndarray | None = None, min_pixels: int = 32, - min_impact: float = 1e-5, + min_impact: float = 3e-6, max_layers: int = 128, fill_holes: bool = True, ) -> list[MaskLayer]: @@ -993,7 +1012,7 @@ def retrieve_layers( masks: list[np.ndarray] | None = None, *, min_pixels: int = 32, - min_impact: float = 1e-5, + min_impact: float = 3e-6, max_layers: int = 512, fill_holes: bool = True, max_side: int | None = SAMVG_MAX_SIDE, @@ -1482,7 +1501,7 @@ def generate_svg( masks: list[np.ndarray] | None = None, *, min_pixels: int = 32, - min_impact: float = 1e-5, + min_impact: float = 3e-6, max_layers: int = 512, segments: int = 16, fill_holes: bool = True, @@ -1674,7 +1693,7 @@ def vectorize_svg( rasterize, steps: int = 500, min_pixels: int = 32, - min_impact: float = 1e-5, + min_impact: float = 3e-6, max_layers: int = 512, segments: int = 16, max_side: int | None = SAMVG_MAX_SIDE, diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index fea618b7..cd738680 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -397,6 +397,44 @@ def test_components_fill_only_tiny_enclosed_holes(): assert components[0].all() +def test_bounded_component_hole_checks_match_full_canvas_semantics(): + def full_canvas(mask: np.ndarray, min_pixels: int) -> list[np.ndarray]: + result = [] + for runs in samvg._run_components(mask): + if sum(end - start for _y, start, end in runs) < min_pixels: + continue + component = np.zeros(mask.shape, dtype=bool) + for y, start, end in runs: + component[y, start:end] = True + for hole in samvg._run_components(~component): + area = sum(end - start for _y, start, end in hole) + touches_border = any( + y in {0, mask.shape[0] - 1} or start == 0 or end == mask.shape[1] + for y, start, end in hole + ) + if area <= min_pixels and not touches_border: + for y, start, end in hole: + component[y, start:end] = True + result.append(component) + return result + + mask = np.zeros((20, 24), dtype=bool) + mask[1:12, 1:12] = True + mask[4:6, 4:6] = False + mask[7:10, 7:10] = False + mask[3:5, 18:20] = True + mask[14:17, 2:5] = True + + bounded = _components(mask, min_pixels=4) + original = full_canvas(mask, min_pixels=4) + + assert len(bounded) == len(original) + assert all( + np.array_equal(left, right) + for left, right in zip(bounded, original, strict=True) + ) + + def test_internal_morphology_matches_scipy_default_connectivity(): mask = np.array( [[False, True, True], [True, True, True], [True, True, True]], dtype=bool From 142397d19fa3d1a610bb7b279a74f9b06dd25fb6 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 10:45:49 +0200 Subject: [PATCH 13/57] docs: add SAMVG algorithm reference --- src/vectrify/refine/samvg.md | 181 +++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/vectrify/refine/samvg.md diff --git a/src/vectrify/refine/samvg.md b/src/vectrify/refine/samvg.md new file mode 100644 index 00000000..fb38d9e0 --- /dev/null +++ b/src/vectrify/refine/samvg.md @@ -0,0 +1,181 @@ +# SAMVG algorithm reference + +This is a pseudocode reference for the two-phase method described in Chapter 3 +of Yiding Zhu's *SAMVG* dissertation. It is a behavioural specification for +Vectrify's SAMVG-inspired path, not a copy of unreleased research code. + +The ordering below matters. In particular, impact is scored for a complete +cleaned SAM mask before that mask is split into connected components for path +tracing. Coverage prompts and residual prompts solve different problems and +must not be conflated. + +## Parameters defined by the dissertation + +```text +AUTOMATIC_POINT_GRID = 32 x 32 +RESIDUAL_THRESHOLD = 0.784 +FIT_STEPS_PER_PHASE = 500 +``` + +The dissertation leaves the SAM checkpoint, SAM confidence/stability gates, +small-region and hole thresholds, impact threshold, circular-kernel radius, +and optimiser hyperparameters as implementation choices. Keep those as +explicit parameters and benchmark them; do not infer a canonical value from a +path-count target alone. + +## Data types + +```text +Mask = boolean H x W image +PaintedMask = (mask: Mask, colour: RGB) +Path = closed filled SVG path +Document = ordered list of SVG elements +Canvas = RGB H x W image +``` + +`Composite(canvas, mask, colour)` paints `colour` wherever `mask` is true. +`Error(target, canvas)` is the pixel reconstruction error used consistently +within a filtering pass. For a blank initial canvas, uncovered pixels receive +the maximum error so the first mask is not biased toward bright colours. + +## Common mask preparation and impact filter + +```text +function CLEAN_MASKS(raw_masks): + # This is SAM automatic-mask post-processing, before SAMVG selection. + masks = retain masks passing SAM's predicted-quality/stability gates + masks = remove configured small connected regions and small holes + return masks + + +function FILTER_BY_IMPACT(target, masks, initial_canvas, min_improvement): + # Each entry remains a WHOLE cleaned SAM mask until it is accepted. + candidates = CLEAN_MASKS(masks) + candidates = sort candidates by descending mask area + + canvas = copy(initial_canvas) + accepted = [] + + for mask in candidates: + colour = mean_rgb(target pixels where mask is true) + proposal = Composite(canvas, mask, colour) + + improvement = Error(target, canvas) - Error(target, proposal) + if improvement < min_improvement: + continue + + accepted.append((mask, colour, improvement)) + canvas = proposal + + return accepted, canvas +``` + +Do **not** score disconnected components of one SAM mask independently. Split +an accepted mask only when tracing it: every connected component becomes its +own editable SVG path, inherits the accepted mask colour, and preserves the +accepted mask's painter-order slot. + +```text +function TRACE_ACCEPTED_MASKS(accepted_masks): + document = [] + for (mask, colour, _) in accepted_masks in acceptance order: + for component in connected_components(mask): + contour = extract_outer_contour(component) + path = fit_fixed_segment_bezier_path(contour) + document.append(filled_path(path, colour)) + return document +``` + +## Phase 1: segmentation, coverage recovery, and first fit + +```text +function FIRST_PHASE(target): + raw = SAM_AUTOMATIC_MASK_GENERATION( + target, + point_grid = AUTOMATIC_POINT_GRID, + crop_schedule = SAM_AMG_CROP_SCHEDULE, + ) + + # Begin on a blank canvas and retain useful whole masks in area order. + first_masks, mask_canvas = FILTER_BY_IMPACT( + target, raw, blank_canvas(target.size), min_improvement + ) + + # This recovery looks for regions with no retained-mask coverage. It is + # still part of segmentation, before any SVG path optimisation. + uncovered = NOT union(mask for (mask, _, _) in first_masks) + coverage_map = circular_convolution(uncovered) + coverage_centres = component_centres(threshold(coverage_map)) + coverage_raw = SAM_PROMPTED_MASKS(target, coverage_centres) + + # Score newly prompted masks against the retained-mask composite, not a + # fresh blank canvas, and append accepted masks after existing masks. + coverage_masks, _ = FILTER_BY_IMPACT( + target, coverage_raw, mask_canvas, min_improvement + ) + seed = TRACE_ACCEPTED_MASKS(first_masks + coverage_masks) + + first_fit = OPTIMISE_PATHS(seed, target, steps = FIT_STEPS_PER_PHASE) + return seed, first_fit +``` + +The coverage pass finds *uncovered* areas. It cannot recover texture inside a +large filled mask that already covers the relevant pixels. + +## Phase 2: residual-detail recovery and second fit + +```text +function SECOND_PHASE(target, first_fit): + fitted_canvas = RASTERISE(first_fit) + difference = sum_over_rgb(abs(target - fitted_canvas)) + + # Unlike coverage recovery, this detects high-error regions after fitting, + # including regions that are already alpha-covered by a coarse fill. + residual_map = circular_convolution(difference) + residual_regions = connected_components( + threshold(residual_map, RESIDUAL_THRESHOLD) + ) + residual_centres = centres(residual_regions) + residual_raw = SAM_PROMPTED_MASKS(target, residual_centres) + + # Filter against the fitted render so only new masks that reduce remaining + # error are kept. Append their paths in painter order after first_fit. + residual_masks, _ = FILTER_BY_IMPACT( + target, residual_raw, fitted_canvas, min_improvement + ) + additions = TRACE_ACCEPTED_MASKS(residual_masks) + + recovered_document = append_in_painter_order(first_fit, additions) + final_fit = OPTIMISE_PATHS( + recovered_document, target, steps = FIT_STEPS_PER_PHASE + ) + return recovered_document, final_fit +``` + +## End-to-end procedure + +```text +function SAMVG(target): + seed, first_fit = FIRST_PHASE(target) + recovered_document, final_fit = SECOND_PHASE(target, first_fit) + return { + seed, + first_fit, + recovered_document, + final_fit, + } +``` + +## Implementation invariants + +- Preserve document/painter order throughout; accepted additions come after + the canvas against which their impact was measured. +- Use the same cleanup, complete-mask impact filtering, and fixed-segment + tracing procedure for automatic, coverage-prompted, and residual-prompted + masks. +- Keep coverage recovery and residual recovery as separate passes. A high + coverage percentage does not show that residual detail recovery is needless. +- Judge a run by raster error and visual output, not just retained-mask or + emitted-path count. Component splitting can make these counts diverge. +- Optional stroke/text handling is an extension around this fill pipeline; it + must not alter the fill-mask ordering or residual-recovery criteria. From 81f8b424e34ffe9faae3b621bbc4d64aebfc5f88 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 11:08:01 +0200 Subject: [PATCH 14/57] fix: align SAMVG mask recovery stages --- scripts/bench_samvg_two_phase.py | 6 +-- src/vectrify/refine/samvg.py | 69 +++++++++++++++++--------------- tests/refine/test_samvg.py | 34 ++++++++++++++++ 3 files changed, 74 insertions(+), 35 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index 69bb8c48..3b73ce63 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -26,7 +26,6 @@ from vectrify.refine.samvg import ( _append_layers, _mse, - _render_layers, _render_svg, filter_by_impact, prompted_masks, @@ -108,13 +107,14 @@ def run_target( initial, target, plugin, steps ) points = residual_prompt_points(target, first_render) - _canvas, coverage = _render_layers((target.height, target.width), layers) added = filter_by_impact( target, prompted_masks(target, points), existing=layers, initial_canvas=np.asarray(first_render, dtype=np.uint8), - initial_coverage=coverage, + # The residual pass starts from the first fitted raster. It is not an + # uncovered-mask pass, so all pixels must use their actual raster MSE. + initial_coverage=np.ones((target.height, target.width), dtype=bool), )[len(layers) :] recovery = _append_layers(first, added, 16, hybrid_strokes=False) final, final_render, final_measurements, final_accepted = _fit_if_improved( diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index e6d6dc23..4e98ceee 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -39,11 +39,10 @@ # grid. 64 doubles the old 32 while leaving full-resolution-mask # headroom on a 16 GB GPU; users with larger cards can raise it by environment. SAMVG_POINTS_PER_BATCH = int(os.environ.get("VECTRIFY_SAMVG_POINTS_PER_BATCH", "64")) -# SAMVG's own impact filter selects useful masks against the image. Retaining -# AMG's score gates here discarded the small facial candidates needed by the -# photo seed before that image-aware test could evaluate them. -SAMVG_PRED_IOU_THRESH = 0.0 -SAMVG_STABILITY_SCORE_THRESH = 0.0 +# Preserve SAM AMG's confidence and stability filtering before SAMVG evaluates +# a complete cleaned mask by render impact, as described in the dissertation. +SAMVG_PRED_IOU_THRESH = 0.88 +SAMVG_STABILITY_SCORE_THRESH = 0.95 # The SAMVG seed only needs OCR once and does it after SAM has released its # automatic-mask pipeline. This is a real VLM pass, not a separate small OCR # detector: it can decide which visible labels deserve editable text and place @@ -549,11 +548,9 @@ def _filter_automatic_masks( original_size: list[int], cropped_box_image: Any, ) -> tuple[Any, Any, Any]: - """Apply AMG's score/edge filter without its lossy RLE round trip.""" - import torch + """Apply AMG's crop-edge filter after low-resolution score filtering.""" from transformers.models.sam.image_processing_sam import ( _batched_mask_to_box, - _compute_stability_score, _is_box_near_crop_edge, _pad_masks, ) @@ -561,13 +558,11 @@ def _filter_automatic_masks( original_height, original_width = original_size scores = iou_scores.reshape(-1) masks = masks.reshape(-1, *masks.shape[-2:]) - keep = torch.ones(len(masks), dtype=torch.bool, device=masks.device) - if SAMVG_PRED_IOU_THRESH > 0: - keep &= scores > SAMVG_PRED_IOU_THRESH - if SAMVG_STABILITY_SCORE_THRESH > 0: - stability = _compute_stability_score(masks, 0, 1) - keep &= stability > SAMVG_STABILITY_SCORE_THRESH - scores, masks = scores[keep], masks[keep] > 0 + # Candidates arrive here as binary low-resolution masks. Their predicted + # IoU and stability were evaluated against decoder logits in + # `_low_resolution_candidates`; repeating AMG's stability test after this + # conversion would compare a binary mask to ``+1`` and discard everything. + masks = masks > 0 boxes = _batched_mask_to_box(masks) keep = ~_is_box_near_crop_edge( boxes, cropped_box_image, [0, 0, original_width, original_height] @@ -882,19 +877,24 @@ def filter_by_impact( error_map = _impact_error_map(target, canvas, coverage) error_total = float(error_map.sum(dtype=np.float64)) error = error_total / error_map.size - initial_count = len(accepted) - candidates = [ - component - for mask in masks - if np.asarray(mask).shape == (height, width) - for component in _components( - np.asarray(mask, dtype=bool), min_pixels, fill_holes=fill_holes + # SAMVG filters an AMG *mask* by its rendered impact, after AMG's component + # cleanup. Components are independent paths only in the subsequent tracing + # stage. Scoring every disconnected component here changes the paper's + # painter-order decision and promotes low-information rectangular fragments. + candidates = [] + for raw_mask in masks: + if np.asarray(raw_mask).shape != (height, width): + continue + components = _components( + np.asarray(raw_mask, dtype=bool), min_pixels, fill_holes=fill_holes ) - ] - candidates.sort(key=lambda mask: int(mask.sum()), reverse=True) - for mask in candidates: - if int(mask.sum()) < min_pixels: + if not components: continue + mask = np.logical_or.reduce(components) + candidates.append((mask, components)) + candidates.sort(key=lambda candidate: int(candidate[0].sum()), reverse=True) + retained: list[tuple[list[np.ndarray], tuple[int, int, int], float]] = [] + for mask, components in candidates: colour = cast( tuple[int, int, int], tuple(int(value) for value in np.rint(target[mask].mean(axis=0))), @@ -910,7 +910,7 @@ def filter_by_impact( impact = error - next_error if impact < min_impact: continue - accepted.append(MaskLayer(mask, colour, impact)) + retained.append((components, colour, impact)) canvas[mask] = colour coverage |= mask error_map[mask] = next_error_values @@ -918,8 +918,12 @@ def filter_by_impact( # Each SAMVG stage is allowed its own retained-mask budget. Applying # this to the combined existing+new list silently limited recovery to # one path once the automatic stage had filled its budget. - if len(accepted) - initial_count >= max_layers: + if len(retained) >= max_layers: break + for components, colour, impact in retained: + accepted.extend( + MaskLayer(component, colour, impact) for component in components + ) return accepted @@ -1034,7 +1038,6 @@ def retrieve_layers( max_layers=max_layers, fill_holes=fill_holes, ) - layers = recolour_visible_layers(image, layers) points = coverage_prompt_points(layers, (image.height, image.width)) prompted = prompted_masks(image, points, max_side=max_side, _runtime=runtime) recovered = filter_by_impact( @@ -1046,7 +1049,6 @@ def retrieve_layers( max_layers=max_layers, fill_holes=fill_holes, ) - recovered = recolour_visible_layers(image, recovered) log.info( "SAMVG first pass: %d automatic mask(s), %d retained; %d coverage " "prompt(s), %d prompted mask(s), %d total retained.", @@ -1727,13 +1729,16 @@ def vectorize_svg( initial, image, rasterize=rasterize, steps=steps ) points = residual_prompt_points(image, first_render) - _canvas, coverage = _render_layers((image.height, image.width), layers) added = filter_by_impact( image, prompted_masks(image, points, max_side=max_side, _runtime=runtime), existing=layers, initial_canvas=np.asarray(first_render, dtype=np.uint8), - initial_coverage=coverage, + # Residual recovery scores against the fitted raster, not a blank + # segmentation canvas. Every pixel therefore has ordinary raster + # error; marking holes in the old masks uncovered would falsely + # reward any prompted mask placed there. + initial_coverage=np.ones((image.height, image.width), dtype=bool), min_pixels=min_pixels, min_impact=min_impact, max_layers=max_layers, diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index cd738680..1835db85 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -307,6 +307,40 @@ def test_filter_by_impact_keeps_useful_nested_masks_in_layer_order(): assert all(layer.impact > 0 for layer in layers) +def test_filter_by_impact_scores_a_disconnected_mask_before_emitting_components(): + pixels = np.zeros((12, 12, 3), dtype=np.uint8) + pixels[2:5, 2:5] = (220, 20, 20) + pixels[7:10, 7:10] = (20, 20, 220) + image = Image.fromarray(pixels) + mask = np.zeros((12, 12), dtype=bool) + mask[2:5, 2:5] = True + mask[7:10, 7:10] = True + + layers = filter_by_impact(image, [mask], min_pixels=1, min_impact=0) + + assert [int(layer.mask.sum()) for layer in layers] == [9, 9] + assert {layer.colour for layer in layers} == {(120, 20, 120)} + assert layers[0].impact == layers[1].impact + + +def test_filter_by_impact_residual_canvas_does_not_charge_covered_pixels_as_blank(): + image = Image.new("RGB", (8, 8), (128, 128, 128)) + mask = np.zeros((8, 8), dtype=bool) + mask[2:6, 2:6] = True + fitted = np.full((8, 8, 3), 128, dtype=np.uint8) + + layers = filter_by_impact( + image, + [mask], + initial_canvas=fitted, + initial_coverage=np.ones((8, 8), dtype=bool), + min_pixels=1, + min_impact=1e-6, + ) + + assert layers == [] + + def test_incremental_impact_scoring_matches_full_canvas_recomputation(): pixels = np.full((16, 16, 3), 255, dtype=np.uint8) pixels[2:12, 2:12] = (180, 60, 30) From 7998c2e8ef7f777c395f9fdde8ebb0e272537275 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 11:45:52 +0200 Subject: [PATCH 15/57] fix: preserve SAMVG contour topology --- scripts/bench_samvg_two_phase.py | 8 ++++++ src/vectrify/refine/paths.py | 17 +++++++++++++ src/vectrify/refine/samvg.py | 6 +++-- tests/refine/test_filled_paths.py | 41 +++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index 3b73ce63..6992cc29 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -103,9 +103,13 @@ def run_target( 16, hybrid_strokes=False, ) + _render_svg(initial, target, plugin.rasterize).save(destination / "first-seed.png") + (destination / "first-seed.svg").write_text(initial) first, first_render, first_measurements, first_accepted = _fit_if_improved( initial, target, plugin, steps ) + first_render.save(destination / "first-fit.png") + (destination / "first-fit.svg").write_text(first) points = residual_prompt_points(target, first_render) added = filter_by_impact( target, @@ -117,6 +121,10 @@ def run_target( initial_coverage=np.ones((target.height, target.width), dtype=bool), )[len(layers) :] recovery = _append_layers(first, added, 16, hybrid_strokes=False) + _render_svg(recovery, target, plugin.rasterize).save( + destination / "residual-recovery.png" + ) + (destination / "residual-recovery.svg").write_text(recovery) final, final_render, final_measurements, final_accepted = _fit_if_improved( recovery, target, plugin, steps ) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 4f2d16ec..987d2daa 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1510,6 +1510,21 @@ def fit_filled_svg( ] ) + def close_contours() -> None: + """Restore the shared joins of every traced closed Bezier contour. + + SAMVG traces closed fixed-segment loops. The packed parameter storage + keeps their cubic endpoints as separate Adam values for efficient + rasterisation, so project them back to a continuous closed contour + after each update. Otherwise a subpixel gap becomes an extra implicit + SVG closing cubic on export, violating the fixed-segment invariant. + """ + with torch.no_grad(): + for path in controls: + for contour in path: + contour[1:, 0].copy_(contour[:-1, 3]) + contour[-1, 3].copy_(contour[0, 0]) + def tile_for(path: list[Any]) -> tuple[int, int, int, int]: """A fixed, antialiased raster tile covering a path's control hull.""" points = torch.cat([control.detach().reshape(-1, 2) for control in path]) @@ -1868,6 +1883,7 @@ def rasterise_multi_group( loss.backward() point_optimizer.step() colour_optimizer.step() + close_contours() continue # First composite the exact same soft fills without recording an @@ -1987,6 +2003,7 @@ def layer_loss( ).backward() point_optimizer.step() colour_optimizer.step() + close_contours() coordinate_scale_cpu = coordinate_scale.cpu() for index, ((element, _contours, _colour, _fill_rule), path) in enumerate( diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 4e98ceee..d4b2e047 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -41,8 +41,10 @@ SAMVG_POINTS_PER_BATCH = int(os.environ.get("VECTRIFY_SAMVG_POINTS_PER_BATCH", "64")) # Preserve SAM AMG's confidence and stability filtering before SAMVG evaluates # a complete cleaned mask by render impact, as described in the dissertation. -SAMVG_PRED_IOU_THRESH = 0.88 -SAMVG_STABILITY_SCORE_THRESH = 0.95 +SAMVG_PRED_IOU_THRESH = float(os.environ.get("VECTRIFY_SAMVG_PRED_IOU_THRESH", "0.88")) +SAMVG_STABILITY_SCORE_THRESH = float( + os.environ.get("VECTRIFY_SAMVG_STABILITY_SCORE_THRESH", "0.95") +) # The SAMVG seed only needs OCR once and does it after SAM has released its # automatic-mask pipeline. This is a real VLM pass, not a separate small OCR # detector: it can decide which visible labels deserve editable text and place diff --git a/tests/refine/test_filled_paths.py b/tests/refine/test_filled_paths.py index 9428fcda..3e3ff45a 100644 --- a/tests/refine/test_filled_paths.py +++ b/tests/refine/test_filled_paths.py @@ -261,6 +261,47 @@ def test_filled_path_fit_moves_fill_colour_toward_target(): assert _mse(fitted, target) < _mse(SVG, target) +def test_filled_path_fit_preserves_a_closed_contours_segment_count(): + target = Image.new("RGB", (24, 24), "black") + target.paste("red", (4, 4, 16, 16)) + + fitted = fit_filled_svg( + SVG, + target, + steps=1, + point_learning_rate=0.5, + color_learning_rate=0.0, + ) + + root = ET.fromstring(fitted) + path = next(element for element in root.iter() if element.get("d")) + assert [len(contour) for contour in parse_filled_cubics(path.get("d", ""))] == [4] + + +def test_filled_path_fit_preserves_each_compound_contours_closure(): + svg = ( + '' + '' + "" + ) + + fitted = fit_filled_svg( + svg, + Image.new("RGB", (24, 24), "black"), + steps=1, + point_learning_rate=0.5, + color_learning_rate=0.0, + ) + + root = ET.fromstring(fitted) + path = next(element for element in root.iter() if element.get("d")) + assert [len(contour) for contour in parse_filled_cubics(path.get("d", ""))] == [ + 4, + 4, + ] + + def test_local_fill_fit_changes_only_one_bounded_group(): paths = [] for index in range(20): From b694ce0ccb0ae739c3f70281c4cc9b709751da7f Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 12:06:22 +0200 Subject: [PATCH 16/57] fix: recover all SAMVG prompt components --- src/vectrify/refine/samvg.py | 65 ++++++++++++++++++++---------------- tests/refine/test_samvg.py | 1 + 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index d4b2e047..680a5230 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -934,7 +934,7 @@ def coverage_prompt_points( shape: tuple[int, int], *, radius_fraction: float = 0.06, - max_points: int = 16, + max_points: int | None = None, ) -> list[tuple[int, int]]: """Find mean-shift centres of large circles untouched by retained masks.""" _canvas, coverage = _render_layers(shape, layers) @@ -950,7 +950,8 @@ def coverage_prompt_points( ((float(distance[round(y), round(x)]), round(x), round(y)) for x, y in centres), reverse=True, ) - return [(x, y) for _distance, x, y in ranked[:max_points]] + selected = ranked if max_points is None else ranked[:max_points] + return [(x, y) for _distance, x, y in selected] def prompted_masks( @@ -982,32 +983,36 @@ def prompted_masks( if runtime.processor is None: runtime.processor = SamProcessor(runtime.generator.image_processor) try: - input_points = [[[list(point)] for point in points]] - inputs = runtime.processor( - images=image, input_points=input_points, return_tensors="pt" - ).to(device) - if ( - runtime.embedding_size == image.size - and runtime.image_embeddings is not None - ): - # The full-image automatic pass has already encoded these pixels. - # Retain only decoder inputs for the coverage/residual prompts. - inputs.pop("pixel_values") - inputs["image_embeddings"] = runtime.image_embeddings - with torch.inference_mode(), _sam_autocast(): - output = runtime.generator.model(**inputs) - post = runtime.processor.image_processor.post_process_masks( - output.pred_masks.detach().cpu(), - inputs["original_sizes"].detach().cpu(), - inputs["reshaped_input_sizes"].detach().cpu(), - )[0] - return [ - _restore_mask( - np.asarray(post[prompt, candidate], dtype=bool), original_size + output_masks = [] + for start in range(0, len(points), SAMVG_POINTS_PER_BATCH): + batch = points[start : start + SAMVG_POINTS_PER_BATCH] + input_points = [[[list(point)] for point in batch]] + inputs = runtime.processor( + images=image, input_points=input_points, return_tensors="pt" + ).to(device) + if ( + runtime.embedding_size == image.size + and runtime.image_embeddings is not None + ): + # The full-image automatic pass has already encoded these pixels. + # Retain only decoder inputs for the coverage/residual prompts. + inputs.pop("pixel_values") + inputs["image_embeddings"] = runtime.image_embeddings + with torch.inference_mode(), _sam_autocast(): + output = runtime.generator.model(**inputs) + post = runtime.processor.image_processor.post_process_masks( + output.pred_masks.detach().cpu(), + inputs["original_sizes"].detach().cpu(), + inputs["reshaped_input_sizes"].detach().cpu(), + )[0] + output_masks.extend( + _restore_mask( + np.asarray(post[prompt, candidate], dtype=bool), original_size + ) + for prompt in range(post.shape[0]) + for candidate in range(post.shape[1]) ) - for prompt in range(post.shape[0]) - for candidate in range(post.shape[1]) - ] + return output_masks finally: if own_runtime and torch.cuda.is_available(): torch.cuda.empty_cache() @@ -1559,7 +1564,7 @@ def residual_prompt_points( *, radius_fraction: float = 0.06, threshold: float = 0.784, - max_points: int = 16, + max_points: int | None = None, ) -> list[tuple[int, int]]: """Locate SAMVG's convolved, thresholded residual components.""" import torch @@ -1589,7 +1594,9 @@ def residual_prompt_points( points.append( (float(smoothed[ys, xs].mean()), round(xs.mean()), round(ys.mean())) ) - return [(x, y) for _score, x, y in sorted(points, reverse=True)[:max_points]] + ranked = sorted(points, reverse=True) + selected = ranked if max_points is None else ranked[:max_points] + return [(x, y) for _score, x, y in selected] def _append_layers( diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 1835db85..c874ca1c 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -591,6 +591,7 @@ def test_coverage_prompt_points_selects_the_centre_of_a_large_empty_region(): [MaskLayer(occupied, (10, 20, 30), 1.0)], (32, 32), radius_fraction=0.15, + max_points=10, ) assert points From 1912d5f8f5058dfa93cb90b401a02e77c7eeffb5 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 12:19:56 +0200 Subject: [PATCH 17/57] tune: calibrate SAMVG residual kernel --- src/vectrify/refine/samvg.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 680a5230..fe0bf848 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -45,6 +45,12 @@ SAMVG_STABILITY_SCORE_THRESH = float( os.environ.get("VECTRIFY_SAMVG_STABILITY_SCORE_THRESH", "0.95") ) +# The dissertation specifies a fixed circular residual kernel scaled to the +# image, but not its fraction. Cat calibration selects this value by final +# raster error and complexity; callers can reproduce alternate sweeps. +SAMVG_RESIDUAL_RADIUS_FRACTION = float( + os.environ.get("VECTRIFY_SAMVG_RESIDUAL_RADIUS_FRACTION", "0.0085") +) # The SAMVG seed only needs OCR once and does it after SAM has released its # automatic-mask pipeline. This is a real VLM pass, not a separate small OCR # detector: it can decide which visible labels deserve editable text and place @@ -1562,7 +1568,7 @@ def residual_prompt_points( target: Image.Image, rendered: Image.Image, *, - radius_fraction: float = 0.06, + radius_fraction: float = SAMVG_RESIDUAL_RADIUS_FRACTION, threshold: float = 0.784, max_points: int | None = None, ) -> list[tuple[int, int]]: From 41a1d93da46169c224b7d38d99412ec7db0feef5 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 12:38:03 +0200 Subject: [PATCH 18/57] feat: add SAMVG learnable fill alpha --- scripts/bench_samvg_two_phase.py | 13 +++++- src/vectrify/refine/paths.py | 78 ++++++++++++++++++++++++++----- tests/refine/test_filled_paths.py | 19 ++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index 6992cc29..dec7246e 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -67,6 +67,7 @@ def _fit_if_improved( target: Image.Image, plugin: SvgPlugin, steps: int, + learn_alpha: bool, ) -> tuple[str, Image.Image, list[dict[str, int | float]], bool]: before = _render_svg(svg, target, plugin.rasterize) measurements: list[dict[str, int | float]] = [] @@ -76,6 +77,7 @@ def _fit_if_improved( rasterize=plugin.rasterize, steps=steps, measurements=measurements, + learn_alpha=learn_alpha, ) after = _render_svg(candidate, target, plugin.rasterize) if _mse(target, after) <= _mse(target, before): @@ -89,6 +91,7 @@ def run_target( *, steps: int, reference_svg: Path | None = None, + learn_alpha: bool = False, ) -> None: target = Image.open(target_path).convert("RGB") plugin = SvgPlugin() @@ -106,7 +109,7 @@ def run_target( _render_svg(initial, target, plugin.rasterize).save(destination / "first-seed.png") (destination / "first-seed.svg").write_text(initial) first, first_render, first_measurements, first_accepted = _fit_if_improved( - initial, target, plugin, steps + initial, target, plugin, steps, learn_alpha ) first_render.save(destination / "first-fit.png") (destination / "first-fit.svg").write_text(first) @@ -126,7 +129,7 @@ def run_target( ) (destination / "residual-recovery.svg").write_text(recovery) final, final_render, final_measurements, final_accepted = _fit_if_improved( - recovery, target, plugin, steps + recovery, target, plugin, steps, learn_alpha ) stages = [ ("target", target, None), @@ -198,6 +201,11 @@ def main() -> None: "--output", type=Path, default=ROOT / "bench/results/samvg-two-phase" ) parser.add_argument("--steps", type=int, default=500) + parser.add_argument( + "--learn-alpha", + action="store_true", + help="Use the dissertation's SAMVG+alpha fitter variation.", + ) args = parser.parse_args() targets = list(args.target) if args.cat or args.all: @@ -222,6 +230,7 @@ def main() -> None: args.output, steps=args.steps, reference_svg=reference_svg, + learn_alpha=args.learn_alpha, ) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 987d2daa..753d3216 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1365,11 +1365,15 @@ def fit_filled_svg( monolithic: bool | None = None, curve_samples: int | None = None, backdrop: Image.Image | None = None, + learn_alpha: bool = False, ) -> str: - """Optimise opaque filled cubic SVG paths against an RGB target. + """Optimise filled cubic SVG paths against an RGB target. SAMVG optimises opaque path coordinates and fill colours for 500 Adam - iterations in each of its two passes. This implementation reuses + iterations in each of its two passes. ``learn_alpha`` enables the + dissertation's SAMVG+alpha variation: each selected path receives a + learnable fill opacity. The standard SAMVG configuration deliberately + keeps it disabled and treats fills as opaque. This implementation reuses Vectrify's torch renderer instead of requiring DiffVG, while retaining the dissertation's full-resolution Adam defaults: point LR 1, colour LR .01, and MSE plus .02 Xing loss. It uses DiffVG's standard 2x2 optimisation @@ -1386,6 +1390,16 @@ def fit_filled_svg( import torch root = ET.fromstring(svg) + + def opacity(element) -> float: + """Read the directly applied SVG fill opacity, clamped for Adam.""" + try: + fill_opacity = float(element.get("fill-opacity", "1")) + element_opacity = float(element.get("opacity", "1")) + except ValueError: + return 1.0 + return min(1.0, max(0.0, fill_opacity * element_opacity)) + entries = [] for element in root.iter(): if element.tag.split("}")[-1] != "path" or not element.get("d"): @@ -1400,7 +1414,7 @@ def fit_filled_svg( fill_rule = element.get("fill-rule", "nonzero").strip().lower() if fill_rule not in {"evenodd", "nonzero"}: continue - entries.append((element, contours, colour, fill_rule)) + entries.append((element, contours, colour, fill_rule, opacity(element))) if not entries: raise UnsupportedPathError("no opaque filled cubic paths to optimise") @@ -1436,7 +1450,7 @@ def fit_filled_svg( ) for contour in contours ] - for _element, contours, _colour, _fill_rule in entries + for _element, contours, _colour, _fill_rule, _opacity in entries ] # A detailed SAMVG seed has hundreds of contours. Keeping each one as a # separate Adam parameter turns one optimiser update into hundreds of tiny @@ -1464,7 +1478,7 @@ def fit_filled_svg( # avoids launching Adam's tiny update kernels once per SVG layer. color_storage = torch.nn.Parameter( torch.tensor( - [colour for _element, _contours, colour, _fill_rule in entries], + [colour for _element, _contours, colour, _fill_rule, _opacity in entries], dtype=torch.float32, device=device, ) @@ -1488,11 +1502,24 @@ def fit_filled_svg( device=device, ) ) + alpha_values = ( + torch.nn.Parameter( + torch.tensor( + [entry[4] for entry in entries], + dtype=torch.float32, + device=device, + ) + ) + if learn_alpha + else None + ) point_optimizer = torch.optim.Adam( [control_storage], lr=point_learning_rate, fused=device == "cuda" ) colour_optimizer = torch.optim.Adam( - [color_storage], lr=color_learning_rate, fused=device == "cuda" + [color_storage, *([] if alpha_values is None else [alpha_values])], + lr=color_learning_rate, + fused=device == "cuda", ) # The dissertation averages Xing within each contour then sums contours. # Keep that weighting while evaluating the 413 cat contours in one CUDA @@ -1864,6 +1891,8 @@ def rasterise_multi_group( if alphas[index] is None: alphas[index] = rasterise_multi(index, path) alpha_stack = torch.stack([alpha for alpha in alphas if alpha is not None]) + if alpha_values is not None: + alpha_stack = alpha_stack * alpha_values.clamp(0, 1)[:, None, None] composite = ( _compiled_opaque_fill_composite() if goal.is_cuda @@ -1916,8 +1945,15 @@ def rasterise_multi_group( before: list[Any] = [] rendered = torch.zeros_like(goal) if under is None else under + opacity_values = ( + alpha_values.detach().clamp(0, 1) if alpha_values is not None else None + ) + initial_coverages: list[Any | None] = initial_alphas.copy() for index, alpha in enumerate(initial_alphas): assert alpha is not None + if opacity_values is not None: + alpha = alpha * opacity_values[index] + initial_alphas[index] = alpha colour = color_storage[index] before.append(rendered) rendered = ( @@ -1942,6 +1978,7 @@ def layer_loss( alphas: list[Any | None] = initial_alphas, suffixes: list[Any | None] = downstream, canvases: list[Any] = before, + coverages: list[Any | None] = initial_coverages, gradient: Any = image_gradient, ) -> Any: stored_alpha = alphas[index] @@ -1951,12 +1988,20 @@ def layer_loss( colour = color_storage[index] colour_delta = colour.detach().clamp(0, 1) - canvases[index] alpha_gradient = (gradient * suffix[..., None] * colour_delta).sum(dim=-1) + opacity = ( + alpha_values[index].clamp(0, 1) if alpha_values is not None else None + ) colour_gradient = ( gradient * suffix[..., None] * stored_alpha[..., None] ).sum(dim=(0, 1)) - return (alpha * alpha_gradient.detach()).sum() + ( - colour.clamp(0, 1) * colour_gradient.detach() - ).sum() + geometry_loss = (alpha * alpha_gradient.detach()).sum() + if opacity is not None: + geometry_loss = geometry_loss * opacity + coverage = coverages[index] + assert coverage is not None + opacity_gradient = (coverage * alpha_gradient.detach()).sum() + geometry_loss = geometry_loss + opacity * opacity_gradient + return geometry_loss + (colour.clamp(0, 1) * colour_gradient.detach()).sum() # Backpropagate a bounded batch at a time. The compositing derivative # above accounts for all later opaque layers, so this has the same MSE @@ -2006,7 +2051,7 @@ def layer_loss( close_contours() coordinate_scale_cpu = coordinate_scale.cpu() - for index, ((element, _contours, _colour, _fill_rule), path) in enumerate( + for index, ((element, _contours, _colour, _fill_rule, _opacity), path) in enumerate( zip(entries, controls, strict=True) ): colour = color_storage[index] @@ -2019,6 +2064,11 @@ def layer_loss( round(float(v) * 255) for v in colour.detach().clamp(0, 1).cpu() ) element.set("fill", f"#{red:02x}{green:02x}{blue:02x}") + if alpha_values is not None: + element.set( + "fill-opacity", + f"{float(alpha_values[index].detach().clamp(0, 1).cpu()):.8g}", + ) return ET.tostring(root, encoding="unicode") @@ -2204,8 +2254,9 @@ def fit_opaque_fills_locally( selected_indices: set[int] | None = None, optimisation_long_side: int | None = 64, gpu_gate: Any = None, + learn_alpha: bool = False, ) -> str: - """Fit one spatially bounded opaque-fill group as a local-search move. + """Fit one spatially bounded fill group as a local-search move. Unlike the legacy stroke fitter this operates on complete filled shapes, including compound paths and holes. It deliberately keeps the 64px @@ -2250,6 +2301,7 @@ def fit_opaque_fills_locally( steps=steps, optimisation_long_side=optimisation_long_side, backdrop=backdrop, + learn_alpha=learn_alpha, ) fitted_root = ET.fromstring(fitted) fitted_by_index = dict(enumerate(fitted_root.iter())) @@ -2259,6 +2311,8 @@ def fit_opaque_fills_locally( updated = fitted_by_index[index] element.set("d", updated.get("d", "")) element.set("fill", updated.get("fill", element.get("fill", ""))) + if learn_alpha: + element.set("fill-opacity", updated.get("fill-opacity", "1")) return ET.tostring(original, encoding="unicode") @@ -2271,6 +2325,7 @@ def fit_filled_svg_bounded( maximum_paths: int = 16, gpu_gate: Any = None, measurements: list[dict[str, int | float]] | None = None, + learn_alpha: bool = False, ) -> str: """Run one full SAMVG fill phase as bounded spatial coordinate descent. @@ -2309,6 +2364,7 @@ def fit_filled_svg_bounded( selected_indices=group, optimisation_long_side=None, gpu_gate=gpu_gate, + learn_alpha=learn_alpha, ) if measurements is not None: peak = peak_before diff --git a/tests/refine/test_filled_paths.py b/tests/refine/test_filled_paths.py index 3e3ff45a..b4001bd7 100644 --- a/tests/refine/test_filled_paths.py +++ b/tests/refine/test_filled_paths.py @@ -261,6 +261,25 @@ def test_filled_path_fit_moves_fill_colour_toward_target(): assert _mse(fitted, target) < _mse(SVG, target) +def test_filled_path_fit_can_learn_fill_opacity(): + source = SVG.replace('#0000ff"', '#ff0000" fill-opacity="1"') + target = Image.new("RGB", (24, 24), "black") + target.paste("#400000", (4, 4, 16, 16)) + + fitted = fit_filled_svg( + source, + target, + steps=12, + point_learning_rate=0.0, + color_learning_rate=0.1, + learn_alpha=True, + ) + + root = ET.fromstring(fitted) + path = next(element for element in root.iter() if element.get("d")) + assert 0 < float(path.get("fill-opacity", "0")) < 1 + + def test_filled_path_fit_preserves_a_closed_contours_segment_count(): target = Image.new("RGB", (24, 24), "black") target.paste("red", (4, 4, 16, 16)) From c050c9bb9080e0071dacbd51529796a7a24fe74c Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 12:40:35 +0200 Subject: [PATCH 19/57] feat: add SAMVG variable segment tracing --- scripts/bench_samvg_two_phase.py | 16 ++++- src/vectrify/refine/samvg.md | 16 +++++ src/vectrify/refine/samvg.py | 119 +++++++++++++++++++++++++++---- tests/refine/test_samvg.py | 12 ++++ 4 files changed, 149 insertions(+), 14 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index dec7246e..18b19443 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -92,6 +92,7 @@ def run_target( steps: int, reference_svg: Path | None = None, learn_alpha: bool = False, + curvature_threshold: float | None = None, ) -> None: target = Image.open(target_path).convert("RGB") plugin = SvgPlugin() @@ -105,6 +106,7 @@ def run_target( layers, 16, hybrid_strokes=False, + curvature_threshold=curvature_threshold, ) _render_svg(initial, target, plugin.rasterize).save(destination / "first-seed.png") (destination / "first-seed.svg").write_text(initial) @@ -123,7 +125,13 @@ def run_target( # uncovered-mask pass, so all pixels must use their actual raster MSE. initial_coverage=np.ones((target.height, target.width), dtype=bool), )[len(layers) :] - recovery = _append_layers(first, added, 16, hybrid_strokes=False) + recovery = _append_layers( + first, + added, + 16, + hybrid_strokes=False, + curvature_threshold=curvature_threshold, + ) _render_svg(recovery, target, plugin.rasterize).save( destination / "residual-recovery.png" ) @@ -193,6 +201,11 @@ def main() -> None: type=Path, help="Reference SVG to Cairo-rasterize alongside a single target.", ) + parser.add_argument( + "--curvature-threshold", + type=float, + help="Use the dissertation's variable-segment tracing variation.", + ) parser.add_argument("--cat", action="store_true") parser.add_argument( "--all", action="store_true", help="Run cat, duck, and all bench targets." @@ -231,6 +244,7 @@ def main() -> None: steps=args.steps, reference_svg=reference_svg, learn_alpha=args.learn_alpha, + curvature_threshold=args.curvature_threshold, ) diff --git a/src/vectrify/refine/samvg.md b/src/vectrify/refine/samvg.md index fb38d9e0..a0077e0a 100644 --- a/src/vectrify/refine/samvg.md +++ b/src/vectrify/refine/samvg.md @@ -23,6 +23,22 @@ and optimiser hyperparameters as implementation choices. Keep those as explicit parameters and benchmark them; do not infer a canonical value from a path-count target alone. +## Reported representation variations + +The baseline uses a fixed number of cubic segments per closed contour and +opaque fills. The dissertation also reports two independent variations: + +```text +SAMVG+var = select locally distinct contour points whose curvature score + crosses a caller-selected threshold, then fit one cubic between + each adjacent selected pair +SAMVG+alpha = make every path fill opacity an optimisation parameter +``` + +These are representation changes, not mask-selection changes. They must be +enabled explicitly when comparing with an SVG made by either variation; the +threshold value itself is not specified by the dissertation. + ## Data types ```text diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index fe0bf848..98425afb 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -1104,18 +1104,24 @@ def _loops(mask: np.ndarray) -> list[list[tuple[float, float]]]: return loops -def _corners(loop: list[tuple[float, float]], count: int) -> list[int]: - """Global curvature maxima with the local exclusion SAMVG describes.""" +def _curvature_scores(loop: list[tuple[float, float]]) -> np.ndarray: + """Return SAMVG's scale-aware cosine curvature score for a contour.""" points = np.asarray(loop, dtype=np.float32) size = len(points) - count = min(count, size) step = max(1, size // 12) before = points - np.roll(points, step, axis=0) after = np.roll(points, -step, axis=0) - points denom = np.linalg.norm(before, axis=1) * np.linalg.norm(after, axis=1) - score = np.divide( + return np.divide( (before * after).sum(axis=1), denom, out=np.ones(size), where=denom > 0 ) + + +def _corners(loop: list[tuple[float, float]], count: int) -> list[int]: + """Global curvature maxima with the local exclusion SAMVG describes.""" + size = len(loop) + count = min(count, size) + score = _curvature_scores(loop) blocked = np.zeros(size, dtype=bool) chosen: list[int] = [] exclusion = max(1, size // (count * 2)) @@ -1132,6 +1138,42 @@ def _corners(loop: list[tuple[float, float]], count: int) -> list[int]: return sorted(chosen) +def _variable_corners( + loop: list[tuple[float, float]], *, threshold: float, maximum: int +) -> list[int]: + """Select locally distinct curvature extrema below SAMVG+var's threshold. + + The dissertation's variable-segment variation replaces the fixed top-N + selection with a curvature threshold. Its threshold is not published, so + callers must choose it explicitly. ``maximum`` is only a safety bound for + pathological raster staircases, not a target complexity. + """ + size = len(loop) + if size < 3: + return [] + score = _curvature_scores(loop) + eligible = np.flatnonzero(score <= threshold) + if len(eligible) < 3: + return _corners(loop, min(3, size)) + # Keep one representative from each local curvature neighbourhood. The + # radius scales with the user safety limit, unlike the fixed 16-segment + # procedure above, so detailed contours remain able to grow when needed. + exclusion = max(1, size // (max(maximum, 3) * 2)) + blocked = np.zeros(size, dtype=bool) + chosen: list[int] = [] + for index in eligible[np.argsort(score[eligible], kind="stable")]: + if blocked[index]: + continue + chosen.append(int(index)) + offsets = (np.arange(index - exclusion, index + exclusion + 1) % size).astype( + int + ) + blocked[offsets] = True + if len(chosen) == maximum: + break + return sorted(chosen) if len(chosen) >= 3 else _corners(loop, min(3, size)) + + def _fit_cubic( points: np.ndarray, *, reparameterize: bool = True ) -> tuple[np.ndarray, np.ndarray]: @@ -1204,11 +1246,23 @@ def solve(parameters: np.ndarray) -> np.ndarray: return controls[0], controls[1] -def _cubic_loop(loop: list[tuple[float, float]], segments: int) -> str | None: +def _cubic_loop( + loop: list[tuple[float, float]], + segments: int, + *, + curvature_threshold: float | None = None, + maximum_segments: int = 512, +) -> str | None: size = len(loop) if size < 3: return None - corners = _corners(loop, segments) + corners = ( + _corners(loop, segments) + if curvature_threshold is None + else _variable_corners( + loop, threshold=curvature_threshold, maximum=maximum_segments + ) + ) if len(corners) < 3: return None points = np.asarray(loop, dtype=np.float32) @@ -1229,12 +1283,28 @@ def _cubic_loop(loop: list[tuple[float, float]], segments: int) -> str | None: def mask_path( - mask: np.ndarray, *, segments: int = 8, overlap_pixels: int = 0 + mask: np.ndarray, + *, + segments: int = 8, + overlap_pixels: int = 0, + curvature_threshold: float | None = None, + maximum_segments: int = 512, ) -> str | None: - """Fit every mask contour as a fixed-count cubic Bezier SVG path.""" + """Fit every mask contour as fixed-count or thresholded cubic Beziers.""" if overlap_pixels: mask = _binary_dilation(mask, overlap_pixels) - parts = [piece for loop in _loops(mask) if (piece := _cubic_loop(loop, segments))] + parts = [ + piece + for loop in _loops(mask) + if ( + piece := _cubic_loop( + loop, + segments, + curvature_threshold=curvature_threshold, + maximum_segments=maximum_segments, + ) + ) + ] return " ".join(parts) or None @@ -1484,7 +1554,12 @@ def mask_strokes( def _layer_svg_attributes( - layer: MaskLayer, segments: int, *, hybrid_strokes: bool = True + layer: MaskLayer, + segments: int, + *, + hybrid_strokes: bool = True, + curvature_threshold: float | None = None, + maximum_segments: int = 512, ) -> list[dict[str, str]]: """Trace one SAM mask, using optional strokes only outside the thesis mode.""" colour = f"#{layer.colour[0]:02x}{layer.colour[1]:02x}{layer.colour[2]:02x}" @@ -1505,7 +1580,13 @@ def _layer_svg_attributes( } for data, width in strokes ] - data = mask_path(layer.mask, segments=segments, overlap_pixels=layer.overlap_pixels) + data = mask_path( + layer.mask, + segments=segments, + overlap_pixels=layer.overlap_pixels, + curvature_threshold=curvature_threshold, + maximum_segments=maximum_segments, + ) if data is None: return [] return [{"d": data, "fill": colour, "fill-rule": "evenodd"}] @@ -1519,6 +1600,8 @@ def generate_svg( min_impact: float = 3e-6, max_layers: int = 512, segments: int = 16, + curvature_threshold: float | None = None, + maximum_segments: int = 512, fill_holes: bool = True, hybrid_strokes: bool = True, ocr: bool = True, @@ -1549,7 +1632,11 @@ def generate_svg( paths = [] for layer in layers: for attributes in _layer_svg_attributes( - layer, segments, hybrid_strokes=hybrid_strokes + layer, + segments, + hybrid_strokes=hybrid_strokes, + curvature_threshold=curvature_threshold, + maximum_segments=maximum_segments, ): markup = " ".join(f'{key}="{value}"' for key, value in attributes.items()) paths.append(f"") @@ -1611,12 +1698,18 @@ def _append_layers( segments: int, *, hybrid_strokes: bool = True, + curvature_threshold: float | None = None, + maximum_segments: int = 512, ) -> str: """Add newly prompted paths to an already optimised SVG.""" root = ET.fromstring(svg) for layer in layers: for attributes in _layer_svg_attributes( - layer, segments, hybrid_strokes=hybrid_strokes + layer, + segments, + hybrid_strokes=hybrid_strokes, + curvature_threshold=curvature_threshold, + maximum_segments=maximum_segments, ): ET.SubElement( root, diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index c874ca1c..89281bd1 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -504,6 +504,18 @@ def test_mask_path_keeps_a_hole_as_a_second_even_odd_subpath(): assert path.count(" Z") == 2 +def test_mask_path_supports_the_variable_segment_tracing_variation(): + mask = np.zeros((48, 48), dtype=bool) + mask[8:40, 8:40] = True + mask[16:32, 16:32] = False + + path = mask_path(mask, curvature_threshold=0.8, maximum_segments=6) + + assert path is not None + assert path.count("M ") == 2 + assert 6 <= path.count("C ") <= 12 + + def test_generate_svg_creates_editable_layered_paths_from_supplied_masks(): image = Image.new("RGB", (10, 8), "white") pixels = np.asarray(image).copy() From 8f9d8f324e4c9b074f03ba26cb94ce25d0966e58 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 12:46:45 +0200 Subject: [PATCH 20/57] feat: render long SAMVG contours on CUDA --- src/vectrify/refine/paths.py | 37 +++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 753d3216..122fe705 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -661,21 +661,30 @@ def _fill_batched_windings( """ import torch - # The packaged native operator uses SAMVG's fixed 16-cubic contour - # representation. It remains deliberately narrow: every - # other contour/layout continues through the proven Torch implementation. + # The native primitive is fixed-width, but winding is additive over cubic + # ranges. Chunk a long contour into padded 16-cubic ranges and sum its + # exact native winding fields before applying the SVG fill rule. This is + # the same representation as the fixed SAMVG path, not a tessellation or + # geometry approximation, and keeps SAMVG+var off Torch's huge broadcast + # fallback. if samples in {8, 16, 32}: from vectrify.refine.cuda_renderer import winding as cuda_winding + chunks = math.ceil(controls.shape[1] / _FUSED_CUBICS) + padded = controls + if chunks > 1: + count = chunks * _FUSED_CUBICS - controls.shape[1] + point = controls[:, :1, :1].expand(-1, count, 4, -1) + padded = torch.cat((controls, point), dim=1) native = cuda_winding( - controls, + padded.reshape(-1, _FUSED_CUBICS, 4, 2), box, samples=samples, x_offset=x_offset, y_offset=y_offset, ) if native is not None: - return native + return native.reshape(len(controls), chunks, *native.shape[1:]).sum(dim=1) left, top, right, bottom = box height, width = bottom - top, right - left @@ -1614,6 +1623,24 @@ def rasterise_simple( tile_height: int, items: list[tuple[int, int, int]], ) -> list[tuple[int, Any]]: + # SAMVG+var can emit a contour longer than the fixed-width coverage + # primitive. Route those through the chunked native winding path; + # packing them into the old batched coverage call would force eager + # Torch broadcasting over every cubic and pixel. + if controls[items[0][0]][0].shape[0] > _FUSED_CUBICS: + output = [] + for index, left, top in items: + offset = controls[index][0].new_tensor((left, top)) + alpha = _fill_path_coverage( + [controls[index][0] - offset], + (0, 0, tile_width, tile_height), + fill_rule=fill_rule, + samples=samples_for(tile_width, tile_height), + subpixels=subpixels, + fuse=False, + ) + output.append((index, restore_tile(alpha, left, top))) + return output translated = torch.stack( [ controls[index][0] - controls[index][0].new_tensor((left, top)) From d8ac89f6f1cf08551f47924ebb59f97a5ee83769 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 13:06:28 +0200 Subject: [PATCH 21/57] fix: select local SAMVG variable corners --- src/vectrify/refine/samvg.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 98425afb..3696ebab 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -1152,13 +1152,25 @@ def _variable_corners( if size < 3: return [] score = _curvature_scores(loop) - eligible = np.flatnonzero(score <= threshold) + # The variable-segment algorithm is the unmodified local-extrema method, + # unlike the fixed-count variant above which repeatedly chooses global + # extrema. Thresholding every low-scoring raster point changes that + # procedure into an edge-density sampler and wildly over-segments masks. + # Use the same k-neighbourhood that defines the curvature measurement to + # identify a local curvature maximum (a minimum cosine score). + neighbourhood = max(1, size // 12) + local_minimum = np.ones(size, dtype=bool) + for offset in range(1, neighbourhood + 1): + previous = np.roll(score, offset) + following = np.roll(score, -offset) + local_minimum &= (score <= previous) & (score <= following) + eligible = np.flatnonzero(local_minimum & (score <= threshold)) if len(eligible) < 3: return _corners(loop, min(3, size)) - # Keep one representative from each local curvature neighbourhood. The - # radius scales with the user safety limit, unlike the fixed 16-segment - # procedure above, so detailed contours remain able to grow when needed. - exclusion = max(1, size // (max(maximum, 3) * 2)) + # Pixel contours often have equal-valued plateaux at a single geometric + # corner. Coalesce only those ties in the curvature neighbourhood; this + # does not impose a fixed segment count. + exclusion = neighbourhood blocked = np.zeros(size, dtype=bool) chosen: list[int] = [] for index in eligible[np.argsort(score[eligible], kind="stable")]: From 23eb68be42a6b2b226d0c1dba43ef823103c82d8 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 13:06:46 +0200 Subject: [PATCH 22/57] fix: reuse SAM runtime in two-phase benchmark --- scripts/bench_samvg_two_phase.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index 18b19443..81c9f2ef 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -27,6 +27,7 @@ _append_layers, _mse, _render_svg, + _sam_runtime, filter_by_impact, prompted_masks, residual_prompt_points, @@ -99,7 +100,8 @@ def run_target( destination = output / target_path.stem destination.mkdir(parents=True, exist_ok=True) started = perf_counter() - layers = retrieve_layers(target) + runtime = _sam_runtime() + layers = retrieve_layers(target, _runtime=runtime) initial = _append_layers( f'', @@ -118,7 +120,7 @@ def run_target( points = residual_prompt_points(target, first_render) added = filter_by_impact( target, - prompted_masks(target, points), + prompted_masks(target, points, _runtime=runtime), existing=layers, initial_canvas=np.asarray(first_render, dtype=np.uint8), # The residual pass starts from the first fitted raster. It is not an From e99ef1483e734625a50917697d4d9ba4b4127a1f Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 13:23:38 +0200 Subject: [PATCH 23/57] feat: add sparse SAMVG global replay --- src/vectrify/refine/paths.py | 201 +++++++++++++++++++++++++++++- tests/refine/test_filled_paths.py | 27 ++++ 2 files changed, 223 insertions(+), 5 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 122fe705..d2c8ca70 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1375,6 +1375,7 @@ def fit_filled_svg( curve_samples: int | None = None, backdrop: Image.Image | None = None, learn_alpha: bool = False, + sparse_replay: bool = False, ) -> str: """Optimise filled cubic SVG paths against an RGB target. @@ -1393,6 +1394,9 @@ def fit_filled_svg( is available only as an explicit caller-selected preview mode. CUDA uses one monolithic compositor graph for 64px-or-smaller working canvases by default; larger canvases retain the memory-bounded replay. + ``sparse_replay`` retains the same painter-order MSE derivative while + saving layer state only within each path's raster tile; it makes a full + 1024px SAMVG phase practical without a monolithic alpha stack. """ import xml.etree.ElementTree as ET @@ -1617,12 +1621,12 @@ def cropped_simple_groups() -> dict[ ].append((index, left, top)) return groups - def rasterise_simple( + def rasterise_simple_tiles( fill_rule: str, tile_width: int, tile_height: int, items: list[tuple[int, int, int]], - ) -> list[tuple[int, Any]]: + ) -> list[tuple[int, Any, int, int]]: # SAMVG+var can emit a contour longer than the fixed-width coverage # primitive. Route those through the chunked native winding path; # packing them into the old batched coverage call would force eager @@ -1639,7 +1643,7 @@ def rasterise_simple( subpixels=subpixels, fuse=False, ) - output.append((index, restore_tile(alpha, left, top))) + output.append((index, alpha, left, top)) return output translated = torch.stack( [ @@ -1657,10 +1661,23 @@ def rasterise_simple( dynamic_fuse=len(items) >= 4, ) return [ - (index, restore_tile(alpha, left, top)) + (index, alpha, left, top) for (index, left, top), alpha in zip(items, rasterised, strict=True) ] + def rasterise_simple( + fill_rule: str, + tile_width: int, + tile_height: int, + items: list[tuple[int, int, int]], + ) -> list[tuple[int, Any]]: + return [ + (index, restore_tile(alpha, left, top)) + for index, alpha, left, top in rasterise_simple_tiles( + fill_rule, tile_width, tile_height, items + ) + ] + def rasterise_multi(index: int, path: list[Any]) -> Any: # Large paths use fixed conservative candidate tiles. Every tile # sees all contours that can cross one of its horizontal rays, while @@ -1942,6 +1959,140 @@ def rasterise_multi_group( close_contours() continue + if sparse_replay: + # Dense replay previously saved a full alpha, pre-layer canvas and + # downstream-transparency map for every SVG path. Painter-order + # compositing is local to a path's coverage tile, so retain only + # those slices while keeping the current canvas/transparency as + # full images. This is algebraically the same replay derivative. + with torch.no_grad(): + coverages: list[tuple[Any, int, int] | None] = [None] * len(entries) + for ( + _shape, + fill_rule, + tile_width, + tile_height, + ), items in simple_groups.items(): + for index, alpha, left, top in rasterise_simple_tiles( + fill_rule, tile_width, tile_height, items + ): + coverages[index] = (alpha, left, top) + for index, path in enumerate(controls): + if coverages[index] is None: + coverages[index] = (rasterise_multi(index, path), 0, 0) + + opacity_values = ( + alpha_values.detach().clamp(0, 1) + if alpha_values is not None + else None + ) + stored_alphas: list[Any] = [] + before_tiles: list[Any] = [] + rendered = torch.zeros_like(goal) if under is None else under.clone() + for index, item in enumerate(coverages): + assert item is not None + alpha, left, top = item + if opacity_values is not None: + alpha = alpha * opacity_values[index] + bottom, right = top + alpha.shape[0], left + alpha.shape[1] + canvas = rendered[top:bottom, left:right] + before_tiles.append(canvas.clone()) + rendered[top:bottom, left:right] = ( + canvas * (1 - alpha[..., None]) + + color_storage[index].detach().clamp(0, 1) * alpha[..., None] + ) + stored_alphas.append(alpha) + + suffix_tiles: list[Any] = [None] * len(entries) + transparency = torch.ones( + (work_height, work_width), dtype=goal.dtype, device=device + ) + for index in range(len(entries) - 1, -1, -1): + item = coverages[index] + assert item is not None + alpha, left, top = item + bottom, right = top + alpha.shape[0], left + alpha.shape[1] + suffix = transparency[top:bottom, left:right] + suffix_tiles[index] = suffix.clone() + suffix.mul_(1 - stored_alphas[index]) + image_gradient = 2 * (rendered - goal) / rendered.numel() + + def sparse_layer_loss( + index: int, + alpha: Any, + left: int, + top: int, + *, + saved_coverages: list[tuple[Any, int, int] | None] = coverages, + saved_alphas: list[Any] = stored_alphas, + saved_suffixes: list[Any] = suffix_tiles, + saved_canvases: list[Any] = before_tiles, + gradient: Any = image_gradient, + ) -> Any: + item = saved_coverages[index] + assert item is not None + coverage, _stored_left, _stored_top = item + stored_alpha = saved_alphas[index] + suffix = saved_suffixes[index] + canvas = saved_canvases[index] + bottom, right = top + alpha.shape[0], left + alpha.shape[1] + gradient = gradient[top:bottom, left:right] + colour = color_storage[index] + colour_delta = colour.detach().clamp(0, 1) - canvas + alpha_gradient = (gradient * suffix[..., None] * colour_delta).sum( + dim=-1 + ) + opacity = ( + alpha_values[index].clamp(0, 1) + if alpha_values is not None + else None + ) + colour_gradient = ( + gradient * suffix[..., None] * stored_alpha[..., None] + ).sum(dim=(0, 1)) + geometry_loss = (alpha * alpha_gradient.detach()).sum() + if opacity is not None: + geometry_loss = geometry_loss * opacity + geometry_loss = ( + geometry_loss + + opacity * (coverage * alpha_gradient.detach()).sum() + ) + return ( + geometry_loss + + (colour.clamp(0, 1) * colour_gradient.detach()).sum() + ) + + for ( + _shape, + fill_rule, + tile_width, + tile_height, + ), items in simple_groups.items(): + for offset in range(0, len(items), 4): + loss = torch.zeros((), device=device) + for index, alpha, left, top in rasterise_simple_tiles( + fill_rule, tile_width, tile_height, items[offset : offset + 4] + ): + loss = loss + sparse_layer_loss(index, alpha, left, top) + loss.backward() + simple_indices = { + index + for group in simple_groups.values() + for index, _left, _top in group + } + for index, path in enumerate(controls): + if index not in simple_indices: + alpha = rasterise_multi(index, path) + sparse_layer_loss(index, alpha, 0, 0).backward() + ( + xing_weight + * (_xing_penalties(all_controls) * xing_contour_weights).sum() + ).backward() + point_optimizer.step() + colour_optimizer.step() + close_contours() + continue + # First composite the exact same soft fills without recording an # autograd graph. The saved canvases and suffix transparencies are # enough to derive the MSE gradient of each layer independently. @@ -2353,10 +2504,15 @@ def fit_filled_svg_bounded( gpu_gate: Any = None, measurements: list[dict[str, int | float]] | None = None, learn_alpha: bool = False, + global_replay: bool = True, ) -> str: """Run one full SAMVG fill phase as bounded spatial coordinate descent. - ``steps`` is the per-group phase budget. Coordinate descent needs to give + ``steps`` is the per-group phase budget. ``global_replay`` uses the + sparse painter-order replay to give every path the dissertation's one + simultaneous Adam update per iteration without materialising a full alpha + stack. The older coordinate-descent path remains available for local + experiments. Coordinate descent needs to give every group the same fitting opportunity that it would have had in the original global graph; splitting that budget between groups loses detail. It consequently trades wall time for a strictly bounded differentiable @@ -2365,6 +2521,41 @@ def fit_filled_svg_bounded( """ if steps < 1: raise ValueError("steps must be positive") + if global_replay: + import xml.etree.ElementTree as ET + + started = perf_counter() + peak_before = 0 + try: + import torch + + if torch.cuda.is_available(): + torch.cuda.reset_peak_memory_stats() + peak_before = int(torch.cuda.max_memory_allocated()) + except ImportError: + torch = None # type: ignore[assignment] + with gpu_slot(gpu_gate): + fitted = fit_filled_svg( + svg, + target, + steps=steps, + learn_alpha=learn_alpha, + sparse_replay=True, + ) + if measurements is not None: + peak = peak_before + if torch is not None and torch.cuda.is_available(): + torch.cuda.synchronize() + peak = int(torch.cuda.max_memory_allocated()) + measurements.append( + { + "group": 0, + "paths": len(_fittable_fill_elements(ET.fromstring(svg))), + "seconds": perf_counter() - started, + "peak_cuda_bytes": peak, + } + ) + return fitted groups = fill_groups(svg, maximum_paths=maximum_paths) if not groups: raise UnsupportedPathError("no opaque filled cubic paths to optimise") diff --git a/tests/refine/test_filled_paths.py b/tests/refine/test_filled_paths.py index b4001bd7..811ce3b8 100644 --- a/tests/refine/test_filled_paths.py +++ b/tests/refine/test_filled_paths.py @@ -280,6 +280,33 @@ def test_filled_path_fit_can_learn_fill_opacity(): assert 0 < float(path.get("fill-opacity", "0")) < 1 +def test_sparse_fill_replay_matches_dense_replay_update(): + source = SVG.replace( + "", + '', + ) + target = Image.new("RGB", (24, 24), "black") + target.paste("red", (4, 4, 16, 16)) + + dense = fit_filled_svg( + source, + target, + steps=1, + point_learning_rate=0.0, + color_learning_rate=0.1, + ) + sparse = fit_filled_svg( + source, + target, + steps=1, + point_learning_rate=0.0, + color_learning_rate=0.1, + sparse_replay=True, + ) + + assert abs(_mse(dense, target) - _mse(sparse, target)) < 2 + + def test_filled_path_fit_preserves_a_closed_contours_segment_count(): target = Image.new("RGB", (24, 24), "black") target.paste("red", (4, 4, 16, 16)) From d0b27f051e36798906d42a79ffed0ed565298918 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 13:26:18 +0200 Subject: [PATCH 24/57] perf: use analytic coverage for compound fills --- src/vectrify/refine/paths.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index d2c8ca70..1acf2467 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1747,6 +1747,20 @@ def rasterise_multi(index: int, path: list[Any]) -> Any: ) left, top, tile_width, tile_height = initial_multi_tiles[index] offset = path[0].new_tensor((left, top)) + from vectrify.refine.cuda_renderer import multi_coverage + + packed = torch.cat( + [_pad_fused_cubics((control - offset)[None]) for control in path] + ) + analytic = multi_coverage( + packed, + [0, len(path)], + (0, 0, tile_width, tile_height), + subpixels=subpixels, + fill_rule=entries[index][3], + ) + if analytic is not None: + return restore_tile(analytic[0], left, top) alpha = _fill_path_coverage( [control - offset for control in path], (0, 0, tile_width, tile_height), From 33889d1e7e1cd511e2548a44bf90e4de702fb1bd Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 13:27:27 +0200 Subject: [PATCH 25/57] perf: batch sparse fill replay gradients --- src/vectrify/refine/paths.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 1acf2467..a11da2d8 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -2082,10 +2082,14 @@ def sparse_layer_loss( tile_width, tile_height, ), items in simple_groups.items(): - for offset in range(0, len(items), 4): + # Sparse replay keeps only a tile-local graph, so it can + # batch more equal-size paths than the legacy dense replay. + # This reduces native coverage launches without increasing the + # full-canvas memory footprint. + for offset in range(0, len(items), 16): loss = torch.zeros((), device=device) for index, alpha, left, top in rasterise_simple_tiles( - fill_rule, tile_width, tile_height, items[offset : offset + 4] + fill_rule, tile_width, tile_height, items[offset : offset + 16] ): loss = loss + sparse_layer_loss(index, alpha, left, top) loss.backward() From cdf0c27790415510e0a3d312557a9f5f703d56e2 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 13:55:09 +0200 Subject: [PATCH 26/57] perf: batch SAMVG replay tiles --- src/vectrify/refine/paths.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index a11da2d8..32b23a74 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1575,11 +1575,19 @@ def tile_for(path: list[Any]) -> tuple[int, int, int, int]: right = min(work_width, math.ceil(float(points[:, 0].max())) + 2) bottom = min(work_height, math.ceil(float(points[:, 1].max())) + 2) - # Bucket dimensions keep many unrelated small paths in the same CUDA - # batch. Shift a bucket at the canvas edge rather than clipping its - # protected coverage margin. - tile_width = min(work_width, 8 * math.ceil(max(right - left, 1) / 8)) - tile_height = min(work_height, 8 * math.ceil(max(bottom - top, 1) / 8)) + # Bucket dimensions keep unrelated paths in the same CUDA batch. A + # 32px bucket roughly halves the distinct sizes of the 1024px cat + # seed versus 8px buckets while adding only a small protected fringe + # to the right/bottom of each tile. The origin—and therefore every + # coverage sample belonging to the path—remains unchanged. Shift a + # bucket at the canvas edge rather than clipping its antialias margin. + tile_bucket = 32 + tile_width = min( + work_width, tile_bucket * math.ceil(max(right - left, 1) / tile_bucket) + ) + tile_height = min( + work_height, tile_bucket * math.ceil(max(bottom - top, 1) / tile_bucket) + ) left = min(left, work_width - tile_width) top = min(top, work_height - tile_height) return left, top, tile_width, tile_height From 8bdf9dbabf976fd5f4b5185ab9ebacbe8c25abd3 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 14:00:58 +0200 Subject: [PATCH 27/57] fix: honor disabled torch compilation --- src/vectrify/refine/paths.py | 14 ++++++++++++-- tests/refine/test_filled_paths.py | 6 ++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 32b23a74..86ec3705 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -11,6 +11,7 @@ import itertools import logging import math +import os import random import re from collections import defaultdict @@ -580,6 +581,15 @@ def _dynamic_fill_winding_chunk(start: Any, end: Any, pixels: Any) -> Any: return _fill_winding_chunk(start, end, pixels) +def _torch_compile_enabled() -> bool: + """Whether this process permits Torch Dynamo to compile renderer kernels.""" + # PyTorch raises from an already-created ``torch.compile`` wrapper when + # this environment switch is set. Respect it before creating the cached + # wrapper so the advertised eager renderer is actually usable for + # debugging, constrained deployments, and compiler-cache recovery. + return os.environ.get("TORCH_COMPILE_DISABLE", "0") not in {"1", "true", "True"} + + @lru_cache(maxsize=1) def _compiled_fill_winding_chunk() -> Any: """Return the CUDA-fused winding primitive when this torch supports it. @@ -592,7 +602,7 @@ def _compiled_fill_winding_chunk() -> Any: import torch compile_fn = getattr(torch, "compile", None) - if compile_fn is None: + if compile_fn is None or not _torch_compile_enabled(): return _fill_winding_chunk try: return compile_fn( @@ -615,7 +625,7 @@ def _compiled_tiled_fill_winding_chunk() -> Any: import torch compile_fn = getattr(torch, "compile", None) - if compile_fn is None: + if compile_fn is None or not _torch_compile_enabled(): return _fill_winding_chunk try: return compile_fn( diff --git a/tests/refine/test_filled_paths.py b/tests/refine/test_filled_paths.py index 811ce3b8..13dcd72a 100644 --- a/tests/refine/test_filled_paths.py +++ b/tests/refine/test_filled_paths.py @@ -16,6 +16,7 @@ _large_path_tile_candidates, _pad_fused_cubics, _tiled_large_path_coverage, + _torch_compile_enabled, _xing_loss, fit_filled_svg, fit_opaque_fills_locally, @@ -37,6 +38,11 @@ def _sixteen_cubic_circle(torch): )[None] +def test_torch_compile_can_be_explicitly_disabled(monkeypatch): + monkeypatch.setenv("TORCH_COMPILE_DISABLE", "1") + assert not _torch_compile_enabled() + + @pytest.mark.parametrize("samples", [8, 16, 32]) def test_native_winding_matches_torch_forward_and_gradient(samples, monkeypatch): """Release-wheel CUDA path agrees with the portable sampled renderer.""" From f766dd5117c0ead13cde25602d8733f925974c25 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 14:03:35 +0200 Subject: [PATCH 28/57] fix: preserve SAM logits through mask resizing --- src/vectrify/refine/samvg.py | 23 +++++++++++++---------- tests/refine/test_samvg.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 3696ebab..b49142e4 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -531,7 +531,7 @@ def _automatic_forward(inputs: Any, runtime: _SamRuntime) -> dict[str, Any]: def _low_resolution_candidates( pred_masks: Any, iou_scores: Any ) -> tuple[Any, Any, Any]: - """Filter and box decoder masks before full-resolution interpolation.""" + """Filter decoder logits and box their thresholded low-resolution masks.""" import torch from transformers.models.sam.image_processing_sam import ( _batched_mask_to_box, @@ -546,8 +546,12 @@ def _low_resolution_candidates( if SAMVG_STABILITY_SCORE_THRESH > 0: stability = _compute_stability_score(masks, 0, 1) keep &= stability > SAMVG_STABILITY_SCORE_THRESH - masks, scores = masks[keep] > 0, scores[keep] - return masks, scores, _batched_mask_to_box(masks) + # SAM's AMG performs NMS on thresholded 256px masks but delays the actual + # threshold until after its full-resolution interpolation. Keeping the + # logits here is essential: interpolating a binary mask turns every soft + # edge into positive coverage and blunts narrow masks such as cat fur. + masks, scores = masks[keep], scores[keep] + return masks, scores, _batched_mask_to_box(masks > 0) def _filter_automatic_masks( @@ -566,10 +570,9 @@ def _filter_automatic_masks( original_height, original_width = original_size scores = iou_scores.reshape(-1) masks = masks.reshape(-1, *masks.shape[-2:]) - # Candidates arrive here as binary low-resolution masks. Their predicted - # IoU and stability were evaluated against decoder logits in - # `_low_resolution_candidates`; repeating AMG's stability test after this - # conversion would compare a binary mask to ``+1`` and discard everything. + # Candidate logits were scored at decoder resolution and interpolated in + # the same order as SAM's AMG. Threshold only now, after resizing them to + # the crop canvas, so narrow boundaries retain their signed contour. masks = masks > 0 boxes = _batched_mask_to_box(masks) keep = ~_is_box_near_crop_edge( @@ -675,9 +678,9 @@ def _automatic_masks_for( runtime.image_embeddings = embedding runtime.embedding_size = source.size outputs.append(_automatic_forward(inputs, runtime)) - # Keep the GPU bounded to one decoder batch. Binary 256px masks are - # four times smaller than the previous float logits and will undergo - # one global NMS before any full-resolution interpolation. + # Keep the GPU bounded to one decoder batch. NMS and score filtering + # have already retained only the decoder logits that need the + # full-resolution SAM post-processing. outputs[-1]["masks"] = outputs[-1]["masks"].cpu() outputs[-1]["scores"] = outputs[-1]["scores"].cpu() outputs[-1]["boxes"] = outputs[-1]["boxes"].cpu() diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 89281bd1..362549cf 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -265,6 +265,21 @@ def __call__(self, source, **kwargs): assert all(mask.shape == (8, 12) for mask in masks) +def test_low_resolution_candidates_keep_logits_until_interpolation(monkeypatch): + torch = __import__("torch") + monkeypatch.setattr(samvg, "SAMVG_PRED_IOU_THRESH", 0.0) + monkeypatch.setattr(samvg, "SAMVG_STABILITY_SCORE_THRESH", 0.0) + logits = torch.tensor([[[[-2.0, 1.0], [1.0, -2.0]]]]) + + masks, scores, boxes = samvg._low_resolution_candidates( + logits, torch.tensor([[[0.9]]]) + ) + + assert torch.allclose(scores, torch.tensor([0.9])) + assert torch.equal(masks, logits.reshape(1, 2, 2)) + assert boxes.shape == (1, 4) + + def test_retrieve_layers_reuses_one_runtime_for_automatic_and_coverage_prompts( monkeypatch, ): From 6c85834fac84c5202311df914d2bf3d32b86f220 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 14:19:28 +0200 Subject: [PATCH 29/57] fix: preserve SAM full-resolution mask filtering --- src/vectrify/refine/samvg.py | 89 ++++++++++++------------------------ tests/refine/test_samvg.py | 15 ------ 2 files changed, 29 insertions(+), 75 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index b49142e4..beec2dfb 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -499,7 +499,7 @@ def _sam_autocast(): def _automatic_forward(inputs: Any, runtime: _SamRuntime) -> dict[str, Any]: - """Decode one prompt batch and retain compact GPU candidates.""" + """Decode and filter one AMG prompt batch in SAM's original order.""" import torch generator = runtime.generator @@ -514,8 +514,22 @@ def _automatic_forward(inputs: Any, runtime: _SamRuntime) -> dict[str, Any]: # that lifetime explicit before handing compact candidates to the host. with torch.inference_mode(), _sam_autocast(): model_outputs = generator.model(**inputs) - masks, scores, boxes = _low_resolution_candidates( - model_outputs.pred_masks, model_outputs.iou_scores + # Official AMG interpolates decoder logits to the crop canvas before + # its confidence, stability, and crop-edge tests. Filtering at 256px + # is faster but changes which fine masks survive, so it cannot be used + # for the dissertation-faithful seed. + masks_at_size = generator.image_processor.post_process_masks( + model_outputs.pred_masks, + original_sizes, + reshaped_input_sizes=reshaped_sizes, + mask_threshold=0, + binarize=False, + )[0] + masks, scores, boxes = _filter_automatic_masks( + masks_at_size, + model_outputs.iou_scores[0], + original_sizes[0], + input_boxes[0], ) return { "masks": masks, @@ -528,41 +542,17 @@ def _automatic_forward(inputs: Any, runtime: _SamRuntime) -> dict[str, Any]: } -def _low_resolution_candidates( - pred_masks: Any, iou_scores: Any -) -> tuple[Any, Any, Any]: - """Filter decoder logits and box their thresholded low-resolution masks.""" - import torch - from transformers.models.sam.image_processing_sam import ( - _batched_mask_to_box, - _compute_stability_score, - ) - - masks = pred_masks.reshape(-1, *pred_masks.shape[-2:]) - scores = iou_scores.reshape(-1) - keep = torch.ones(len(masks), dtype=torch.bool, device=masks.device) - if SAMVG_PRED_IOU_THRESH > 0: - keep &= scores > SAMVG_PRED_IOU_THRESH - if SAMVG_STABILITY_SCORE_THRESH > 0: - stability = _compute_stability_score(masks, 0, 1) - keep &= stability > SAMVG_STABILITY_SCORE_THRESH - # SAM's AMG performs NMS on thresholded 256px masks but delays the actual - # threshold until after its full-resolution interpolation. Keeping the - # logits here is essential: interpolating a binary mask turns every soft - # edge into positive coverage and blunts narrow masks such as cat fur. - masks, scores = masks[keep], scores[keep] - return masks, scores, _batched_mask_to_box(masks > 0) - - def _filter_automatic_masks( masks: Any, iou_scores: Any, original_size: list[int], cropped_box_image: Any, ) -> tuple[Any, Any, Any]: - """Apply AMG's crop-edge filter after low-resolution score filtering.""" + """Apply SAM AMG's full-resolution confidence and crop-edge filtering.""" + import torch from transformers.models.sam.image_processing_sam import ( _batched_mask_to_box, + _compute_stability_score, _is_box_near_crop_edge, _pad_masks, ) @@ -570,10 +560,12 @@ def _filter_automatic_masks( original_height, original_width = original_size scores = iou_scores.reshape(-1) masks = masks.reshape(-1, *masks.shape[-2:]) - # Candidate logits were scored at decoder resolution and interpolated in - # the same order as SAM's AMG. Threshold only now, after resizing them to - # the crop canvas, so narrow boundaries retain their signed contour. - masks = masks > 0 + keep = torch.ones(len(masks), dtype=torch.bool, device=masks.device) + if SAMVG_PRED_IOU_THRESH > 0: + keep &= scores > SAMVG_PRED_IOU_THRESH + if SAMVG_STABILITY_SCORE_THRESH > 0: + keep &= _compute_stability_score(masks, 0, 1) > SAMVG_STABILITY_SCORE_THRESH + masks, scores = masks[keep] > 0, scores[keep] boxes = _batched_mask_to_box(masks) keep = ~_is_box_near_crop_edge( boxes, cropped_box_image, [0, 0, original_width, original_height] @@ -588,7 +580,7 @@ def _filter_automatic_masks( def _finalize_automatic_masks( outputs: list[dict[str, Any]], runtime: _SamRuntime ) -> list[np.ndarray]: - """NMS compact candidates, then expand and transfer only survivors.""" + """NMS SAM's already full-resolution binary candidates and transfer them.""" import torch from torchvision.ops import batched_nms @@ -605,31 +597,8 @@ def _finalize_automatic_masks( ) selected_masks = torch.cat(masks)[keep] selected_scores = scores[keep] - metadata = outputs[0] - expanded: list[np.ndarray] = [] - # A small final batch bounds GPU interpolation memory. Every survivor is - # still expanded with Transformers' exact bilinear mask post-processing. - for start in range(0, len(selected_masks), 16): - stop = start + 16 - masks_at_size = runtime.generator.image_processor.post_process_masks( - [ - selected_masks[start:stop] - .unsqueeze(1) - .to(runtime.generator.device, dtype=torch.float16) - ], - [metadata["original_size"]], - [metadata["reshaped_size"]], - mask_threshold=0, - binarize=False, - )[0] - filtered, _scores, _boxes = _filter_automatic_masks( - masks_at_size, - selected_scores[start:stop].to(runtime.generator.device), - metadata["original_size"], - metadata["crop_box"], - ) - expanded.extend(np.asarray(mask.cpu(), dtype=bool) for mask in filtered) - return expanded + del selected_scores, runtime + return [np.asarray(mask.cpu(), dtype=bool) for mask in selected_masks] def _automatic_masks_for( diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 362549cf..89281bd1 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -265,21 +265,6 @@ def __call__(self, source, **kwargs): assert all(mask.shape == (8, 12) for mask in masks) -def test_low_resolution_candidates_keep_logits_until_interpolation(monkeypatch): - torch = __import__("torch") - monkeypatch.setattr(samvg, "SAMVG_PRED_IOU_THRESH", 0.0) - monkeypatch.setattr(samvg, "SAMVG_STABILITY_SCORE_THRESH", 0.0) - logits = torch.tensor([[[[-2.0, 1.0], [1.0, -2.0]]]]) - - masks, scores, boxes = samvg._low_resolution_candidates( - logits, torch.tensor([[[0.9]]]) - ) - - assert torch.allclose(scores, torch.tensor([0.9])) - assert torch.equal(masks, logits.reshape(1, 2, 2)) - assert boxes.shape == (1, 4) - - def test_retrieve_layers_reuses_one_runtime_for_automatic_and_coverage_prompts( monkeypatch, ): From fbde45be0be4114b2df618d9233b6018312203d4 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 14:19:28 +0200 Subject: [PATCH 30/57] refactor: report SAMVG Cairo MSE --- scripts/bench_samvg_two_phase.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index 81c9f2ef..d50c5d2c 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -43,15 +43,6 @@ def _path_count(svg: str) -> int: ) -def _l1(target: Image.Image, rendered: Image.Image) -> float: - return float( - np.abs( - np.asarray(target.convert("RGB"), dtype=np.float32) / 255.0 - - np.asarray(rendered.convert("RGB"), dtype=np.float32) / 255.0 - ).mean() - ) - - def _write_gallery(images: list[tuple[str, Image.Image]], destination: Path) -> None: width = max(image.width for _name, image in images) height = max(image.height for _name, image in images) @@ -163,7 +154,6 @@ def run_target( rows.append( { "stage": name, - "l1": _l1(target, rendered), "mse": _mse(target, rendered), "paths": _path_count(svg) if svg is not None else 0, } @@ -172,7 +162,7 @@ def run_target( [(name, image) for name, image, _svg in stages], destination / "gallery.png" ) with (destination / "stages.csv").open("w", newline="") as handle: - writer = csv.DictWriter(handle, fieldnames=["stage", "l1", "mse", "paths"]) + writer = csv.DictWriter(handle, fieldnames=["stage", "mse", "paths"]) writer.writeheader() writer.writerows(rows) measurements = [ From a75c2a4155292f81a7d4592e25af362d0b7cd39a Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 14:53:30 +0200 Subject: [PATCH 31/57] fix: refresh moved SAMVG fill tiles --- src/vectrify/refine/paths.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 86ec3705..bd8e00fb 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1881,6 +1881,24 @@ def rasterise_multi_group( # seed. The two-pixel antialias margin already makes these fixed tiles # conservative for the local coordinate updates used by the fit. initial_simple_groups = cropped_simple_groups() + # Simple paths use cropped tiles, so unlike the full-canvas compositor + # their bounds are optimisation state. A SAMVG coordinate update can move + # a boundary outside its initial two-pixel antialias fringe; continuing to + # rasterise the old crop silently clips that fill and creates the holes and + # spikes visible in long fits. Keep one packed reference so the movement + # check is a single device reduction; rebuild the inexpensive Python tile + # grouping only after a meaningful move. + simple_tile_reference = control_storage.detach().clone() + + def refresh_simple_tiles() -> None: + nonlocal initial_simple_groups, simple_tile_reference + + movement = (control_storage.detach() - simple_tile_reference).abs().amax() + if float(movement) <= 1.0: + return + initial_simple_groups = cropped_simple_groups() + simple_tile_reference = control_storage.detach().clone() + initial_multi_tiles = { index: tile_for(path) for index, path in enumerate(controls) @@ -1989,6 +2007,7 @@ def rasterise_multi_group( point_optimizer.step() colour_optimizer.step() close_contours() + refresh_simple_tiles() continue if sparse_replay: @@ -2127,6 +2146,7 @@ def sparse_layer_loss( point_optimizer.step() colour_optimizer.step() close_contours() + refresh_simple_tiles() continue # First composite the exact same soft fills without recording an @@ -2263,6 +2283,7 @@ def layer_loss( point_optimizer.step() colour_optimizer.step() close_contours() + refresh_simple_tiles() coordinate_scale_cpu = coordinate_scale.cpu() for index, ((element, _contours, _colour, _fill_rule, _opacity), path) in enumerate( From 80086755b6ec11b4050c4bc97af1c70410bad294 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 15:01:51 +0200 Subject: [PATCH 32/57] tune: densify SAMVG residual recovery --- src/vectrify/refine/samvg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index beec2dfb..93a3afd4 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -49,7 +49,7 @@ # image, but not its fraction. Cat calibration selects this value by final # raster error and complexity; callers can reproduce alternate sweeps. SAMVG_RESIDUAL_RADIUS_FRACTION = float( - os.environ.get("VECTRIFY_SAMVG_RESIDUAL_RADIUS_FRACTION", "0.0085") + os.environ.get("VECTRIFY_SAMVG_RESIDUAL_RADIUS_FRACTION", "0.005") ) # The SAMVG seed only needs OCR once and does it after SAM has released its # automatic-mask pipeline. This is a real VLM pass, not a separate small OCR From 65fb390e1258211b06e5d817612d75827279ed4e Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 15:13:08 +0200 Subject: [PATCH 33/57] fix: apply global SAM crop NMS --- src/vectrify/refine/samvg.py | 79 +++++++++++++++++++++++++----------- tests/refine/test_samvg.py | 24 +++++++++++ 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 93a3afd4..a97c61de 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -577,44 +577,39 @@ def _filter_automatic_masks( ) -def _finalize_automatic_masks( - outputs: list[dict[str, Any]], runtime: _SamRuntime -) -> list[np.ndarray]: - """NMS SAM's already full-resolution binary candidates and transfer them.""" +def _finalize_automatic_masks(masks: Any, scores: Any, boxes: Any) -> list[np.ndarray]: + """Apply AMG's final image-global NMS and transfer binary masks.""" import torch from torchvision.ops import batched_nms - masks = [output["masks"] for output in outputs if len(output["masks"])] - if not masks: + if not len(masks): return [] - scores = torch.cat([output["scores"] for output in outputs]) - boxes = torch.cat([output["boxes"] for output in outputs]) keep = batched_nms( boxes=boxes.float(), scores=scores.float(), idxs=torch.zeros(len(boxes), dtype=torch.long), iou_threshold=0.7, ) - selected_masks = torch.cat(masks)[keep] - selected_scores = scores[keep] - del selected_scores, runtime + selected_masks = masks[keep] return [np.asarray(mask.cpu(), dtype=bool) for mask in selected_masks] -def _automatic_masks_for( +def _automatic_mask_candidates_for( source: Image.Image, runtime: _SamRuntime, *, cache_embedding: bool, points_per_batch: int = SAMVG_POINTS_PER_BATCH, -) -> list[np.ndarray]: - """Run one AMG image/crop without recomputing prompt-grid embeddings. +) -> tuple[Any, Any, Any]: + """Return post-filter AMG candidates before its image-global crop NMS. Transformers' public mask-generation call already encodes an image once per 32x32 prompt grid. For the full image we use the same pipeline stages directly so the resulting embedding can be reused by coverage/residual prompts. Crops intentionally retain their own embeddings. """ + import torch + generator = runtime.generator arguments = { "points_per_batch": points_per_batch, @@ -626,7 +621,20 @@ def _automatic_masks_for( # Keep a small compatibility path for mocked/older Transformers pipelines. if not hasattr(generator, "preprocess"): output = generator(source, **arguments) - return [np.asarray(mask, dtype=bool) for mask in output["masks"]] + masks = ( + torch.from_numpy( + np.stack([np.asarray(mask, dtype=bool) for mask in output["masks"]]) + ) + if output["masks"] + else torch.empty((0, source.height, source.width), dtype=torch.bool) + ) + boxes = torch.empty((len(masks), 4), dtype=torch.float32) + for index, mask in enumerate(masks): + ys, xs = torch.where(mask) + boxes[index] = torch.tensor( + (xs.min(), ys.min(), xs.max() + 1, ys.max() + 1), dtype=torch.float32 + ) + return masks, torch.ones(len(masks)), boxes outputs = [] for inputs in generator.preprocess( @@ -653,7 +661,18 @@ def _automatic_masks_for( outputs[-1]["masks"] = outputs[-1]["masks"].cpu() outputs[-1]["scores"] = outputs[-1]["scores"].cpu() outputs[-1]["boxes"] = outputs[-1]["boxes"].cpu() - return _finalize_automatic_masks(outputs, runtime) + masks = [output["masks"] for output in outputs if len(output["masks"])] + if not masks: + return ( + torch.empty((0, source.height, source.width), dtype=torch.bool), + torch.empty(0), + torch.empty((0, 4)), + ) + return ( + torch.cat(masks), + torch.cat([output["scores"] for output in outputs if len(output["masks"])]), + torch.cat([output["boxes"] for output in outputs if len(output["masks"])]), + ) def automatic_masks( @@ -674,12 +693,17 @@ def automatic_masks( width, height = image.size def collect(points_per_batch: int) -> list[np.ndarray]: - collected = _automatic_masks_for( + import torch + + masks, scores, boxes = _automatic_mask_candidates_for( image, runtime, cache_embedding=True, points_per_batch=points_per_batch, ) + all_masks = [masks] + all_scores = [scores] + all_boxes = [boxes] overlap = int((512 / 1500) * min(width, height)) crop_width = math.ceil((overlap + width) / 2) crop_height = math.ceil((overlap + height) / 2) @@ -691,18 +715,27 @@ def collect(points_per_batch: int) -> list[np.ndarray]: }: right, bottom = min(x + crop_width, width), min(y + crop_height, height) crop_box = (x, y, right, bottom) - for crop_mask in _automatic_masks_for( + crop_masks, crop_scores, crop_boxes = _automatic_mask_candidates_for( image.crop(crop_box), runtime, cache_embedding=False, points_per_batch=points_per_batch, - ): - if _is_crop_edge_mask(crop_mask, crop_box, image.size): + ) + for index, crop_mask in enumerate(crop_masks): + crop_mask_array = np.asarray(crop_mask, dtype=bool) + if _is_crop_edge_mask(crop_mask_array, crop_box, image.size): continue mask = np.zeros((height, width), dtype=bool) - mask[y:bottom, x:right] = crop_mask - collected.append(mask) - return collected + mask[y:bottom, x:right] = crop_mask_array + all_masks.append(torch.from_numpy(mask)[None]) + all_scores.append(crop_scores[index : index + 1]) + box = crop_boxes[index].clone() + box[[0, 2]] += x + box[[1, 3]] += y + all_boxes.append(box[None]) + return _finalize_automatic_masks( + torch.cat(all_masks), torch.cat(all_scores), torch.cat(all_boxes) + ) collected = collect(SAMVG_POINTS_PER_BATCH) return [_restore_mask(mask, original_size) for mask in collected] diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 89281bd1..76e19277 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -265,6 +265,30 @@ def __call__(self, source, **kwargs): assert all(mask.shape == (8, 12) for mask in masks) +def test_automatic_mask_finalization_suppresses_across_crop_sources(): + import torch + + masks = torch.tensor( + [ + [[True, True], [True, True]], + [[False, False], [False, True]], + ] + ) + # These candidates represent the same image-space crop box. The second + # candidate has a different raster but a higher SAM IoU, so AMG's one + # image-global NMS must retain it instead of allowing each source to keep + # its own duplicate. + retained = samvg._finalize_automatic_masks( + masks, + torch.tensor([0.8, 0.9]), + torch.tensor([[0.0, 0.0, 2.0, 2.0], [0.0, 0.0, 2.0, 2.0]]), + ) + + assert len(retained) == 1 + assert retained[0][1, 1] + assert not retained[0][0, 0] + + def test_retrieve_layers_reuses_one_runtime_for_automatic_and_coverage_prompts( monkeypatch, ): From ccb50c80fc47c34fe2096086d9f35430c4e47ffa Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 15:23:20 +0200 Subject: [PATCH 34/57] fix: match SAM automatic crop suppression --- src/vectrify/refine/samvg.py | 43 +++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index a97c61de..0da21f41 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -577,19 +577,24 @@ def _filter_automatic_masks( ) -def _finalize_automatic_masks(masks: Any, scores: Any, boxes: Any) -> list[np.ndarray]: - """Apply AMG's final image-global NMS and transfer binary masks.""" +def _nms_indices(boxes: Any, scores: Any) -> Any: + """Use SAM AMG's box-NMS configuration with dtype-safe scores.""" import torch from torchvision.ops import batched_nms - if not len(masks): - return [] - keep = batched_nms( + return batched_nms( boxes=boxes.float(), scores=scores.float(), idxs=torch.zeros(len(boxes), dtype=torch.long), iou_threshold=0.7, ) + + +def _finalize_automatic_masks(masks: Any, scores: Any, boxes: Any) -> list[np.ndarray]: + """Apply AMG's final image-global NMS and transfer binary masks.""" + if not len(masks): + return [] + keep = _nms_indices(boxes, scores) selected_masks = masks[keep] return [np.asarray(mask.cpu(), dtype=bool) for mask in selected_masks] @@ -634,7 +639,9 @@ def _automatic_mask_candidates_for( boxes[index] = torch.tensor( (xs.min(), ys.min(), xs.max() + 1, ys.max() + 1), dtype=torch.float32 ) - return masks, torch.ones(len(masks)), boxes + scores = torch.ones(len(masks)) + keep = _nms_indices(boxes, scores) if len(masks) else [] + return masks[keep], scores[keep], boxes[keep] outputs = [] for inputs in generator.preprocess( @@ -668,11 +675,14 @@ def _automatic_mask_candidates_for( torch.empty(0), torch.empty((0, 4)), ) - return ( - torch.cat(masks), - torch.cat([output["scores"] for output in outputs if len(output["masks"])]), - torch.cat([output["boxes"] for output in outputs if len(output["masks"])]), - ) + masks = torch.cat(masks) + scores = torch.cat([output["scores"] for output in outputs if len(output["masks"])]) + boxes = torch.cat([output["boxes"] for output in outputs if len(output["masks"])]) + # Meta's AMG first suppresses candidates within each crop by predicted + # quality. Its second, cross-crop pass happens in ``automatic_masks`` + # below and deliberately ranks the surviving masks by crop area instead. + keep = _nms_indices(boxes, scores) + return masks[keep], scores[keep], boxes[keep] def automatic_masks( @@ -702,17 +712,17 @@ def collect(points_per_batch: int) -> list[np.ndarray]: points_per_batch=points_per_batch, ) all_masks = [masks] - all_scores = [scores] + all_scores = [torch.full_like(scores, 1 / (width * height))] all_boxes = [boxes] overlap = int((512 / 1500) * min(width, height)) crop_width = math.ceil((overlap + width) / 2) crop_height = math.ceil((overlap + height) / 2) - for x, y in { + for x, y in ( (0, 0), (crop_width - overlap, 0), (0, crop_height - overlap), (crop_width - overlap, crop_height - overlap), - }: + ): right, bottom = min(x + crop_width, width), min(y + crop_height, height) crop_box = (x, y, right, bottom) crop_masks, crop_scores, crop_boxes = _automatic_mask_candidates_for( @@ -728,7 +738,10 @@ def collect(points_per_batch: int) -> list[np.ndarray]: mask = np.zeros((height, width), dtype=bool) mask[y:bottom, x:right] = crop_mask_array all_masks.append(torch.from_numpy(mask)[None]) - all_scores.append(crop_scores[index : index + 1]) + crop_area = (right - x) * (bottom - y) + all_scores.append( + torch.full_like(crop_scores[index : index + 1], 1 / crop_area) + ) box = crop_boxes[index].clone() box[[0, 2]] += x box[[1, 3]] += y From 84fdc548ca55cd5573d038a292b072ef1ba7def0 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 15:26:33 +0200 Subject: [PATCH 35/57] fix: preserve SAM crop traversal order --- src/vectrify/refine/samvg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 0da21f41..bf8d6682 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -719,8 +719,8 @@ def collect(points_per_batch: int) -> list[np.ndarray]: crop_height = math.ceil((overlap + height) / 2) for x, y in ( (0, 0), - (crop_width - overlap, 0), (0, crop_height - overlap), + (crop_width - overlap, 0), (crop_width - overlap, crop_height - overlap), ): right, bottom = min(x + crop_width, width), min(y + crop_height, height) From 9486d759dc638201e12ad31e03cc553730663e67 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 15:26:54 +0200 Subject: [PATCH 36/57] docs: record SAM crop suppression order --- src/vectrify/refine/samvg.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vectrify/refine/samvg.md b/src/vectrify/refine/samvg.md index a0077e0a..272b1f10 100644 --- a/src/vectrify/refine/samvg.md +++ b/src/vectrify/refine/samvg.md @@ -111,6 +111,9 @@ function FIRST_PHASE(target): point_grid = AUTOMATIC_POINT_GRID, crop_schedule = SAM_AMG_CROP_SCHEDULE, ) + # AMG removes duplicate candidates in two passes: predicted-IoU NMS + # within each crop, then crop-area-priority NMS across all crop outputs. + # The latter prefers a duplicate from the smaller crop. # Begin on a blank canvas and retain useful whole masks in area order. first_masks, mask_canvas = FILTER_BY_IMPACT( From 33f6f5beaa5f6af55f6d1ce1756d544b25045090 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 16:03:33 +0200 Subject: [PATCH 37/57] fix: retain SAMVG variable trace extrema --- src/vectrify/refine/samvg.py | 56 ++++++++++++++---------------------- tests/refine/test_samvg.py | 12 ++++++++ 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index bf8d6682..de9a4218 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -1159,7 +1159,7 @@ def _corners(loop: list[tuple[float, float]], count: int) -> list[int]: def _variable_corners( loop: list[tuple[float, float]], *, threshold: float, maximum: int ) -> list[int]: - """Select locally distinct curvature extrema below SAMVG+var's threshold. + """Select local curvature extrema below SAMVG+var's threshold. The dissertation's variable-segment variation replaces the fixed top-N selection with a curvature threshold. Its threshold is not published, so @@ -1170,38 +1170,24 @@ def _variable_corners( if size < 3: return [] score = _curvature_scores(loop) - # The variable-segment algorithm is the unmodified local-extrema method, - # unlike the fixed-count variant above which repeatedly chooses global - # extrema. Thresholding every low-scoring raster point changes that - # procedure into an edge-density sampler and wildly over-segments masks. - # Use the same k-neighbourhood that defines the curvature measurement to - # identify a local curvature maximum (a minimum cosine score). - neighbourhood = max(1, size // 12) - local_minimum = np.ones(size, dtype=bool) - for offset in range(1, neighbourhood + 1): - previous = np.roll(score, offset) - following = np.roll(score, -offset) - local_minimum &= (score <= previous) & (score <= following) + # SAMVG+var reverts the fixed variant's global-maxima-with-exclusion rule + # to the conventional local-extrema selector. The curvature *score* + # itself uses k-neighbours (Eq. 3-4); expanding the extrema neighbourhood + # to that same k suppresses genuine nearby corners and is not part of the + # variable-segment procedure. The asymmetric comparison retains one + # representative for a flat raster-corner plateau without coalescing + # separate extrema. + previous = np.roll(score, 1) + following = np.roll(score, -1) + local_minimum = (score < previous) & (score <= following) eligible = np.flatnonzero(local_minimum & (score <= threshold)) if len(eligible) < 3: return _corners(loop, min(3, size)) - # Pixel contours often have equal-valued plateaux at a single geometric - # corner. Coalesce only those ties in the curvature neighbourhood; this - # does not impose a fixed segment count. - exclusion = neighbourhood - blocked = np.zeros(size, dtype=bool) - chosen: list[int] = [] - for index in eligible[np.argsort(score[eligible], kind="stable")]: - if blocked[index]: - continue - chosen.append(int(index)) - offsets = (np.arange(index - exclusion, index + exclusion + 1) % size).astype( - int - ) - blocked[offsets] = True - if len(chosen) == maximum: - break - return sorted(chosen) if len(chosen) >= 3 else _corners(loop, min(3, size)) + return ( + sorted(int(index) for index in eligible[:maximum]) + if len(eligible) >= 3 + else _corners(loop, min(3, size)) + ) def _fit_cubic( @@ -1281,7 +1267,7 @@ def _cubic_loop( segments: int, *, curvature_threshold: float | None = None, - maximum_segments: int = 512, + maximum_segments: int = 2048, ) -> str | None: size = len(loop) if size < 3: @@ -1318,7 +1304,7 @@ def mask_path( segments: int = 8, overlap_pixels: int = 0, curvature_threshold: float | None = None, - maximum_segments: int = 512, + maximum_segments: int = 2048, ) -> str | None: """Fit every mask contour as fixed-count or thresholded cubic Beziers.""" if overlap_pixels: @@ -1589,7 +1575,7 @@ def _layer_svg_attributes( *, hybrid_strokes: bool = True, curvature_threshold: float | None = None, - maximum_segments: int = 512, + maximum_segments: int = 2048, ) -> list[dict[str, str]]: """Trace one SAM mask, using optional strokes only outside the thesis mode.""" colour = f"#{layer.colour[0]:02x}{layer.colour[1]:02x}{layer.colour[2]:02x}" @@ -1631,7 +1617,7 @@ def generate_svg( max_layers: int = 512, segments: int = 16, curvature_threshold: float | None = None, - maximum_segments: int = 512, + maximum_segments: int = 2048, fill_holes: bool = True, hybrid_strokes: bool = True, ocr: bool = True, @@ -1729,7 +1715,7 @@ def _append_layers( *, hybrid_strokes: bool = True, curvature_threshold: float | None = None, - maximum_segments: int = 512, + maximum_segments: int = 2048, ) -> str: """Add newly prompted paths to an already optimised SVG.""" root = ET.fromstring(svg) diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 76e19277..2ed8a0a2 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -540,6 +540,18 @@ def test_mask_path_supports_the_variable_segment_tracing_variation(): assert 6 <= path.count("C ") <= 12 +def test_variable_corners_retains_nearby_local_extrema(monkeypatch): + monkeypatch.setattr( + samvg, + "_curvature_scores", + lambda _loop: np.array((1.0, -0.9, 1.0, -0.8, 1.0, -0.7, 1.0, -0.6)), + ) + + corners = samvg._variable_corners([(0.0, 0.0)] * 8, threshold=0, maximum=16) + + assert corners == [1, 3, 5, 7] + + def test_generate_svg_creates_editable_layered_paths_from_supplied_masks(): image = Image.new("RGB", (10, 8), "white") pixels = np.asarray(image).copy() From edc765e543b222e6f59e41d335698c908bdb621b Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 16:07:55 +0200 Subject: [PATCH 38/57] fix: store variable SAMVG contour lengths --- src/vectrify/refine/paths.py | 20 +++++++++++++++++--- tests/refine/test_filled_paths.py | 13 +++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index bd8e00fb..b390a761 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1477,13 +1477,27 @@ def opacity(element) -> float: ] # A detailed SAMVG seed has hundreds of contours. Keeping each one as a # separate Adam parameter turns one optimiser update into hundreds of tiny - # CUDA kernels. Store fixed-width contour slots in one parameter and use + # CUDA kernels. Store equal-width contour slots in one parameter and use # narrow views below, retaining every original contour length in the SVG - # and Xing terms. + # and Xing terms. The native coverage primitive itself uses 16-cubic + # chunks, but SAMVG+var legitimately emits longer contours; storage must + # therefore use the document maximum rather than that renderer chunk size. flat_controls = [control for path in initial_controls for control in path] contour_sizes = [len(control) for control in flat_controls] + storage_width = max(contour_sizes) + + def pad_storage_control(control: Any) -> Any: + if len(control) == storage_width: + return control + return torch.cat( + ( + control, + control[:1].expand(storage_width - len(control), -1, -1), + ) + ) + control_storage = torch.nn.Parameter( - torch.cat([_pad_fused_cubics(control[None]) for control in flat_controls]) + torch.stack([pad_storage_control(control) for control in flat_controls]) ) controls = [] path_storage_spans = [] diff --git a/tests/refine/test_filled_paths.py b/tests/refine/test_filled_paths.py index 13dcd72a..8fce507b 100644 --- a/tests/refine/test_filled_paths.py +++ b/tests/refine/test_filled_paths.py @@ -724,6 +724,19 @@ def unexpected_sampled_fallback(*_args, **_kwargs): assert "path" in fitted +def test_filled_fit_initialises_a_variable_length_contour(): + commands = " ".join("C 0 0 1 0 2 0" for _ in range(17)) + svg = ( + '' + f'' + "" + ) + + fitted = fit_filled_svg(svg, Image.new("RGB", (8, 8), "white"), steps=0) + + assert "path" in fitted + + def test_bounded_compositing_gradient_matches_monolithic_render(): """The memory-bounded fit pass must retain the full painter's-order MSE gradient.""" torch = pytest.importorskip("torch") From 13748183cc30165a651c020181d1e7183ee2b591 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 16:28:28 +0200 Subject: [PATCH 39/57] fix: preserve SAM mask compound paths --- src/vectrify/refine/samvg.py | 17 ++++++++++------- tests/refine/test_samvg.py | 7 +++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index de9a4218..26e4c649 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -919,8 +919,8 @@ def filter_by_impact( mask = np.logical_or.reduce(components) candidates.append((mask, components)) candidates.sort(key=lambda candidate: int(candidate[0].sum()), reverse=True) - retained: list[tuple[list[np.ndarray], tuple[int, int, int], float]] = [] - for mask, components in candidates: + retained: list[tuple[np.ndarray, tuple[int, int, int], float]] = [] + for mask, _parts in candidates: colour = cast( tuple[int, int, int], tuple(int(value) for value in np.rint(target[mask].mean(axis=0))), @@ -936,7 +936,11 @@ def filter_by_impact( impact = error - next_error if impact < min_impact: continue - retained.append((components, colour, impact)) + # Components are traced as compound subpaths of one accepted SAM mask. + # This preserves every disconnected detail and its one painter-order + # slot, matching the compound-path topology in the thesis SVG; making + # every component a new SVG element only fragments the document. + retained.append((mask, colour, impact)) canvas[mask] = colour coverage |= mask error_map[mask] = next_error_values @@ -946,10 +950,9 @@ def filter_by_impact( # one path once the automatic stage had filled its budget. if len(retained) >= max_layers: break - for components, colour, impact in retained: - accepted.extend( - MaskLayer(component, colour, impact) for component in components - ) + accepted.extend( + MaskLayer(mask, colour, impact) for mask, colour, impact in retained + ) return accepted diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 2ed8a0a2..de380615 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -331,7 +331,7 @@ def test_filter_by_impact_keeps_useful_nested_masks_in_layer_order(): assert all(layer.impact > 0 for layer in layers) -def test_filter_by_impact_scores_a_disconnected_mask_before_emitting_components(): +def test_filter_by_impact_keeps_disconnected_mask_as_one_compound_layer(): pixels = np.zeros((12, 12, 3), dtype=np.uint8) pixels[2:5, 2:5] = (220, 20, 20) pixels[7:10, 7:10] = (20, 20, 220) @@ -342,9 +342,8 @@ def test_filter_by_impact_scores_a_disconnected_mask_before_emitting_components( layers = filter_by_impact(image, [mask], min_pixels=1, min_impact=0) - assert [int(layer.mask.sum()) for layer in layers] == [9, 9] - assert {layer.colour for layer in layers} == {(120, 20, 120)} - assert layers[0].impact == layers[1].impact + assert [int(layer.mask.sum()) for layer in layers] == [18] + assert layers[0].colour == (120, 20, 120) def test_filter_by_impact_residual_canvas_does_not_charge_covered_pixels_as_blank(): From 65ed147ecd7b53d1f249c0df7c330d12e811f1ac Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 16:31:26 +0200 Subject: [PATCH 40/57] revert: preserve SAM mask compound paths --- src/vectrify/refine/samvg.py | 17 +++++++---------- tests/refine/test_samvg.py | 7 ++++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 26e4c649..de9a4218 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -919,8 +919,8 @@ def filter_by_impact( mask = np.logical_or.reduce(components) candidates.append((mask, components)) candidates.sort(key=lambda candidate: int(candidate[0].sum()), reverse=True) - retained: list[tuple[np.ndarray, tuple[int, int, int], float]] = [] - for mask, _parts in candidates: + retained: list[tuple[list[np.ndarray], tuple[int, int, int], float]] = [] + for mask, components in candidates: colour = cast( tuple[int, int, int], tuple(int(value) for value in np.rint(target[mask].mean(axis=0))), @@ -936,11 +936,7 @@ def filter_by_impact( impact = error - next_error if impact < min_impact: continue - # Components are traced as compound subpaths of one accepted SAM mask. - # This preserves every disconnected detail and its one painter-order - # slot, matching the compound-path topology in the thesis SVG; making - # every component a new SVG element only fragments the document. - retained.append((mask, colour, impact)) + retained.append((components, colour, impact)) canvas[mask] = colour coverage |= mask error_map[mask] = next_error_values @@ -950,9 +946,10 @@ def filter_by_impact( # one path once the automatic stage had filled its budget. if len(retained) >= max_layers: break - accepted.extend( - MaskLayer(mask, colour, impact) for mask, colour, impact in retained - ) + for components, colour, impact in retained: + accepted.extend( + MaskLayer(component, colour, impact) for component in components + ) return accepted diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index de380615..2ed8a0a2 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -331,7 +331,7 @@ def test_filter_by_impact_keeps_useful_nested_masks_in_layer_order(): assert all(layer.impact > 0 for layer in layers) -def test_filter_by_impact_keeps_disconnected_mask_as_one_compound_layer(): +def test_filter_by_impact_scores_a_disconnected_mask_before_emitting_components(): pixels = np.zeros((12, 12, 3), dtype=np.uint8) pixels[2:5, 2:5] = (220, 20, 20) pixels[7:10, 7:10] = (20, 20, 220) @@ -342,8 +342,9 @@ def test_filter_by_impact_keeps_disconnected_mask_as_one_compound_layer(): layers = filter_by_impact(image, [mask], min_pixels=1, min_impact=0) - assert [int(layer.mask.sum()) for layer in layers] == [18] - assert layers[0].colour == (120, 20, 120) + assert [int(layer.mask.sum()) for layer in layers] == [9, 9] + assert {layer.colour for layer in layers} == {(120, 20, 120)} + assert layers[0].impact == layers[1].impact def test_filter_by_impact_residual_canvas_does_not_charge_covered_pixels_as_blank(): From 9be163500153a3f8a7eb4918215f17ef2a437c19 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 18:50:49 +0200 Subject: [PATCH 41/57] fix: align SAMVG coverage recovery --- src/vectrify/refine/samvg.py | 91 ++++++++++++++++++++++-------------- tests/refine/test_samvg.py | 30 +++++++----- 2 files changed, 73 insertions(+), 48 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index de9a4218..1ee8986b 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -960,22 +960,61 @@ def coverage_prompt_points( radius_fraction: float = 0.06, max_points: int | None = None, ) -> list[tuple[int, int]]: - """Find mean-shift centres of large circles untouched by retained masks.""" + """Find centres of circularly-smoothed uncovered components. + + This is the first-phase coverage detector from the dissertation. It is + deliberately distinct from the residual detector below: its input is the + binary union of retained masks, so a full-kernel threshold means every + selected point is safely inside an uncovered region. One prompt per + connected component avoids the old mean-shift sampling heuristic, whose + number and placement varied with component area. + """ _canvas, coverage = _render_layers(shape, layers) radius = max(2, round(min(shape) * radius_fraction)) - distance = _distance_transform_edt(~coverage) - ys, xs = np.nonzero(distance >= radius) - if len(xs) == 0: - return [] - stride = max(1, len(xs) // 2_048) - points = np.column_stack((xs[::stride], ys[::stride])) - centres = _mean_shift_centres(points, radius) - ranked = sorted( - ((float(distance[round(y), round(x)]), round(x), round(y)) for x, y in centres), - reverse=True, + return _circular_component_centres( + (~coverage).astype(np.float32), + radius, + # Float32 convolution can undershoot one by a few ulps even for a + # completely uncovered disk. + threshold=1.0 - 1e-6, + max_points=max_points, ) - selected = ranked if max_points is None else ranked[:max_points] - return [(x, y) for _distance, x, y in selected] + + +def _circular_component_centres( + values: np.ndarray, + radius: int, + *, + threshold: float, + max_points: int | None = None, +) -> list[tuple[int, int]]: + """Return ranked centres of thresholded circular-convolution components.""" + import torch + import torch.nn.functional as functional + + if radius < 1: + raise ValueError("radius must be positive") + yy, xx = np.ogrid[-radius : radius + 1, -radius : radius + 1] + kernel = (xx * xx + yy * yy <= radius * radius).astype(np.float32) + padded = np.pad(np.asarray(values, dtype=np.float32), radius, mode="symmetric") + smoothed = functional.conv2d( + torch.from_numpy(padded)[None, None], + torch.from_numpy((kernel / kernel.sum())[None, None]), + )[0, 0].numpy() + labels, count = _label(smoothed >= threshold) + ranked: list[tuple[float, int, int]] = [] + for index in range(1, count + 1): + ys, xs = np.nonzero(labels == index) + if len(xs): + # The mean is the component centre prescribed by SAMVG. Ranking + # by response is deterministic when callers cap prompt count. + ranked.append( + (float(smoothed[ys, xs].mean()), round(xs.mean()), round(ys.mean())) + ) + selected = sorted(ranked, reverse=True) + if max_points is not None: + selected = selected[:max_points] + return [(x, y) for _score, x, y in selected] def prompted_masks( @@ -1676,9 +1715,6 @@ def residual_prompt_points( max_points: int | None = None, ) -> list[tuple[int, int]]: """Locate SAMVG's convolved, thresholded residual components.""" - import torch - import torch.nn.functional as functional - target_pixels = np.asarray(target.convert("RGB"), dtype=np.float32) / 255.0 rendered_pixels = np.asarray(rendered.convert("RGB"), dtype=np.float32) / 255.0 # SAMVG sums RGB-channel difference before applying its 0.784 threshold. @@ -1686,26 +1722,9 @@ def residual_prompt_points( difference = np.abs(target_pixels - rendered_pixels).sum(axis=2) height, width = difference.shape radius = max(2, round(min(height, width) * radius_fraction)) - yy, xx = np.ogrid[-radius : radius + 1, -radius : radius + 1] - kernel = (xx * xx + yy * yy <= radius * radius).astype(np.float32) - # Reflected padding preserves the prior symmetric-boundary definition; - # FFT convolution keeps the full-resolution recovery pass practical. - padded = np.pad(difference, radius, mode="symmetric") - smoothed = functional.conv2d( - torch.from_numpy(padded)[None, None], - torch.from_numpy((kernel / kernel.sum())[None, None]), - )[0, 0].numpy() - labels, count = _label(smoothed >= threshold) - points: list[tuple[float, int, int]] = [] - for index in range(1, count + 1): - ys, xs = np.nonzero(labels == index) - if len(xs): - points.append( - (float(smoothed[ys, xs].mean()), round(xs.mean()), round(ys.mean())) - ) - ranked = sorted(points, reverse=True) - selected = ranked if max_points is None else ranked[:max_points] - return [(x, y) for _score, x, y in selected] + return _circular_component_centres( + difference, radius, threshold=threshold, max_points=max_points + ) def _append_layers( diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 2ed8a0a2..640a5cd4 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -643,18 +643,24 @@ def test_coverage_prompt_points_selects_the_centre_of_a_large_empty_region(): ) assert points - assert points == [ - (27, 20), - (27, 10), - (25, 25), - (25, 15), - (25, 5), - (20, 27), - (20, 20), - (20, 15), - (20, 10), - (20, 4), - ] + # One thresholded circular-convolution component gets one prompt at its + # geometric centre; coverage recovery is no longer area-dependent + # mean-shift sampling. + assert points == [(24, 16)] + + +def test_coverage_prompt_points_keeps_separate_uncovered_components(): + occupied = np.ones((40, 40), dtype=bool) + occupied[4:16, 4:16] = False + occupied[24:36, 24:36] = False + + points = coverage_prompt_points( + [MaskLayer(occupied, (10, 20, 30), 1.0)], + (40, 40), + radius_fraction=0.1, + ) + + assert set(points) == {(10, 10), (30, 30)} def test_residual_points_use_summed_rgb_difference_at_the_paper_threshold(): From 33ca1c933adf65534ac8f8d5824b7462a4ec08d1 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 18:55:05 +0200 Subject: [PATCH 42/57] fix: separate SAMVG benchmark targets --- scripts/bench_samvg_two_phase.py | 51 +++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index d50c5d2c..68aafca1 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -43,6 +43,13 @@ def _path_count(svg: str) -> int: ) +def _result_name(target_path: Path) -> str: + """Give standard bench targets stable, non-colliding output directories.""" + if target_path.name == "target.png": + return target_path.parent.name + return target_path.stem + + def _write_gallery(images: list[tuple[str, Image.Image]], destination: Path) -> None: width = max(image.width for _name, image in images) height = max(image.height for _name, image in images) @@ -85,10 +92,11 @@ def run_target( reference_svg: Path | None = None, learn_alpha: bool = False, curvature_threshold: float | None = None, + seed_only: bool = False, ) -> None: target = Image.open(target_path).convert("RGB") plugin = SvgPlugin() - destination = output / target_path.stem + destination = output / _result_name(target_path) destination.mkdir(parents=True, exist_ok=True) started = perf_counter() runtime = _sam_runtime() @@ -103,6 +111,41 @@ def run_target( ) _render_svg(initial, target, plugin.rasterize).save(destination / "first-seed.png") (destination / "first-seed.svg").write_text(initial) + if seed_only: + seed_render = _render_svg(initial, target, plugin.rasterize) + stages = [("target", target, None), ("first-seed", seed_render, initial)] + if reference_svg is not None: + reference = _render_svg(reference_svg.read_text(), target, plugin.rasterize) + stages.append(("reference-svg", reference, reference_svg.read_text())) + rows = [ + { + "stage": name, + "mse": _mse(target, rendered), + "paths": _path_count(svg) if svg is not None else 0, + } + for name, rendered, svg in stages + ] + _write_gallery( + [(name, image) for name, image, _svg in stages], destination / "gallery.png" + ) + with (destination / "stages.csv").open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["stage", "mse", "paths"]) + writer.writeheader() + writer.writerows(rows) + (destination / "fit-groups.json").write_text("[]\n") + (destination / "summary.json").write_text( + json.dumps( + { + "target": str(target_path), + "seed_only": True, + "initial_layers": len(layers), + "wall_seconds": perf_counter() - started, + "stages": rows, + }, + indent=2, + ) + ) + return first, first_render, first_measurements, first_accepted = _fit_if_improved( initial, target, plugin, steps, learn_alpha ) @@ -206,6 +249,11 @@ def main() -> None: "--output", type=Path, default=ROOT / "bench/results/samvg-two-phase" ) parser.add_argument("--steps", type=int, default=500) + parser.add_argument( + "--seed-only", + action="store_true", + help="Benchmark automatic masks and coverage recovery without fitting.", + ) parser.add_argument( "--learn-alpha", action="store_true", @@ -237,6 +285,7 @@ def main() -> None: reference_svg=reference_svg, learn_alpha=args.learn_alpha, curvature_threshold=args.curvature_threshold, + seed_only=args.seed_only, ) From 6838148302cfa1fa3c359f3d87081c54ff623ae0 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 18:57:35 +0200 Subject: [PATCH 43/57] fix: defer SAMVG mask gates to impact filtering --- src/vectrify/refine/samvg.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 1ee8986b..d60cac32 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -39,11 +39,13 @@ # grid. 64 doubles the old 32 while leaving full-resolution-mask # headroom on a 16 GB GPU; users with larger cards can raise it by environment. SAMVG_POINTS_PER_BATCH = int(os.environ.get("VECTRIFY_SAMVG_POINTS_PER_BATCH", "64")) -# Preserve SAM AMG's confidence and stability filtering before SAMVG evaluates -# a complete cleaned mask by render impact, as described in the dissertation. -SAMVG_PRED_IOU_THRESH = float(os.environ.get("VECTRIFY_SAMVG_PRED_IOU_THRESH", "0.88")) +# SAMVG's image-aware impact filter is the retained-mask decision specified by +# the dissertation. Keep AMG's confidence gates configurable, but disable +# them by default so a small, useful candidate reaches that later test instead +# of being discarded by a checkpoint-confidence heuristic. +SAMVG_PRED_IOU_THRESH = float(os.environ.get("VECTRIFY_SAMVG_PRED_IOU_THRESH", "0")) SAMVG_STABILITY_SCORE_THRESH = float( - os.environ.get("VECTRIFY_SAMVG_STABILITY_SCORE_THRESH", "0.95") + os.environ.get("VECTRIFY_SAMVG_STABILITY_SCORE_THRESH", "0") ) # The dissertation specifies a fixed circular residual kernel scaled to the # image, but not its fraction. Cat calibration selects this value by final From 66e03e8c66bbe1de74994a776e2771b885b0d117 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 19:11:09 +0200 Subject: [PATCH 44/57] feat: report SAMVG mask canvas error --- scripts/bench_samvg_two_phase.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index 68aafca1..106379f9 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -26,6 +26,7 @@ from vectrify.refine.samvg import ( _append_layers, _mse, + _render_layers, _render_svg, _sam_runtime, filter_by_impact, @@ -113,7 +114,14 @@ def run_target( (destination / "first-seed.svg").write_text(initial) if seed_only: seed_render = _render_svg(initial, target, plugin.rasterize) - stages = [("target", target, None), ("first-seed", seed_render, initial)] + mask_canvas, _coverage = _render_layers( + (target.height, target.width), layers + ) + stages = [ + ("target", target, None), + ("mask-canvas", Image.fromarray(mask_canvas), None), + ("first-seed", seed_render, initial), + ] if reference_svg is not None: reference = _render_svg(reference_svg.read_text(), target, plugin.rasterize) stages.append(("reference-svg", reference, reference_svg.read_text())) From 45b2f4641429db36c987be5e9330d89e307710af Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 19:43:46 +0200 Subject: [PATCH 45/57] fix: retain accepted SAMVG first fit --- scripts/bench_samvg_two_phase.py | 5 +++++ src/vectrify/refine/samvg.py | 11 +++++++++-- tests/refine/test_samvg.py | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index 106379f9..c0aacbc3 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -183,6 +183,11 @@ def run_target( final, final_render, final_measurements, final_accepted = _fit_if_improved( recovery, target, plugin, steps, learn_alpha ) + if _mse(target, final_render) > _mse(target, first_render): + # Phase-two fitting is accepted relative to the recovered document, + # but the benchmark's final result must retain the already accepted + # first fit when residual additions regress the exported Cairo raster. + final, final_render, final_accepted = first, first_render, False stages = [ ("target", target, None), ("first-seed", _render_svg(initial, target, plugin.rasterize), initial), diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index d60cac32..f93d1e96 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -1893,12 +1893,19 @@ def vectorize_svg( len(points), len(added), ) - return _accepted_fit( + final, final_render = _accepted_fit( _append_layers(first, added, segments, hybrid_strokes=False), image, rasterize=rasterize, steps=steps, - )[0] + ) + # A locally accepted second fit can still be worse than the first fit + # if its residual additions were harmful. The public two-phase result + # must never discard an already accepted Cairo-raster improvement. + if _mse(image, final_render) <= _mse(image, first_render): + return final + log.info("SAMVG residual phase rejected: it increased exported SVG MSE.") + return first finally: del runtime try: diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 640a5cd4..bcda68df 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -168,6 +168,39 @@ def accepted(svg, _image, *, rasterize, steps): assert all(path.get("stroke") is None for path in paths) +def test_vectorize_svg_rejects_a_residual_phase_that_regresses_first_fit(monkeypatch): + image = Image.new("RGB", (16, 16), "white") + base = np.zeros((16, 16), dtype=bool) + base[2:10, 2:10] = True + added = np.zeros((16, 16), dtype=bool) + added[10:14, 10:14] = True + initial_layer = MaskLayer(base, (10, 20, 30), 1.0) + added_layer = MaskLayer(added, (40, 50, 60), 1.0) + monkeypatch.setattr(samvg, "_sam_runtime", lambda: object()) + monkeypatch.setattr( + samvg, "retrieve_layers", lambda *_args, **_kwargs: [initial_layer] + ) + monkeypatch.setattr(samvg, "residual_prompt_points", lambda *_args: [(12, 12)]) + monkeypatch.setattr(samvg, "prompted_masks", lambda *_args, **_kwargs: [added]) + monkeypatch.setattr( + samvg, + "filter_by_impact", + lambda _image, _masks, **kwargs: [*kwargs["existing"], added_layer], + ) + renders = [image, Image.new("RGB", image.size, "black")] + monkeypatch.setattr( + samvg, + "_accepted_fit", + lambda svg, _image, **_kwargs: (svg, renders.pop(0)), + ) + + result = samvg.vectorize_svg(image, rasterize=SvgPlugin().rasterize, steps=3) + + root = ET.fromstring(result) + path_count = sum(element.tag.endswith("path") for element in root.iter()) + assert path_count == 1 + + def test_generate_svg_writes_detected_words_as_editable_text(monkeypatch): monkeypatch.setattr(samvg, "retrieve_layers", lambda *_args, **_kwargs: []) monkeypatch.setattr( From 19acdbaba993481642fafaee4efc1c7f68eef841 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 19:49:25 +0200 Subject: [PATCH 46/57] fix: restore SAMVG coverage mean shift --- src/vectrify/refine/samvg.md | 3 ++- src/vectrify/refine/samvg.py | 29 +++++++++++++---------------- tests/refine/test_samvg.py | 30 ++++++++++++------------------ 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/vectrify/refine/samvg.md b/src/vectrify/refine/samvg.md index 272b1f10..e8a98b3b 100644 --- a/src/vectrify/refine/samvg.md +++ b/src/vectrify/refine/samvg.md @@ -124,7 +124,8 @@ function FIRST_PHASE(target): # still part of segmentation, before any SVG path optimisation. uncovered = NOT union(mask for (mask, _, _) in first_masks) coverage_map = circular_convolution(uncovered) - coverage_centres = component_centres(threshold(coverage_map)) + coverage_candidates = coordinates_of_full_empty_circles(coverage_map) + coverage_centres = mean_shift_clusters(coverage_candidates) coverage_raw = SAM_PROMPTED_MASKS(target, coverage_centres) # Score newly prompted masks against the retained-mask composite, not a diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index f93d1e96..acbb0450 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -962,25 +962,22 @@ def coverage_prompt_points( radius_fraction: float = 0.06, max_points: int | None = None, ) -> list[tuple[int, int]]: - """Find centres of circularly-smoothed uncovered components. - - This is the first-phase coverage detector from the dissertation. It is - deliberately distinct from the residual detector below: its input is the - binary union of retained masks, so a full-kernel threshold means every - selected point is safely inside an uncovered region. One prompt per - connected component avoids the old mean-shift sampling heuristic, whose - number and placement varied with component area. - """ + """Find mean-shift centres of large circles untouched by retained masks.""" _canvas, coverage = _render_layers(shape, layers) radius = max(2, round(min(shape) * radius_fraction)) - return _circular_component_centres( - (~coverage).astype(np.float32), - radius, - # Float32 convolution can undershoot one by a few ulps even for a - # completely uncovered disk. - threshold=1.0 - 1e-6, - max_points=max_points, + distance = _distance_transform_edt(~coverage) + ys, xs = np.nonzero(distance >= radius) + if len(xs) == 0: + return [] + stride = max(1, len(xs) // 2_048) + points = np.column_stack((xs[::stride], ys[::stride])) + centres = _mean_shift_centres(points, radius) + ranked = sorted( + ((float(distance[round(y), round(x)]), round(x), round(y)) for x, y in centres), + reverse=True, ) + selected = ranked if max_points is None else ranked[:max_points] + return [(x, y) for _distance, x, y in selected] def _circular_component_centres( diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index bcda68df..607dfa02 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -676,24 +676,18 @@ def test_coverage_prompt_points_selects_the_centre_of_a_large_empty_region(): ) assert points - # One thresholded circular-convolution component gets one prompt at its - # geometric centre; coverage recovery is no longer area-dependent - # mean-shift sampling. - assert points == [(24, 16)] - - -def test_coverage_prompt_points_keeps_separate_uncovered_components(): - occupied = np.ones((40, 40), dtype=bool) - occupied[4:16, 4:16] = False - occupied[24:36, 24:36] = False - - points = coverage_prompt_points( - [MaskLayer(occupied, (10, 20, 30), 1.0)], - (40, 40), - radius_fraction=0.1, - ) - - assert set(points) == {(10, 10), (30, 30)} + assert points == [ + (27, 20), + (27, 10), + (25, 25), + (25, 15), + (25, 5), + (20, 27), + (20, 20), + (20, 15), + (20, 10), + (20, 4), + ] def test_residual_points_use_summed_rgb_difference_at_the_paper_threshold(): From 3a474f56a08ab0522eace9d1f2f680b826a926b1 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 20:09:20 +0200 Subject: [PATCH 47/57] feat: expose SAMVG representation variations --- src/vectrify/refine/samvg.py | 29 +++++++++++++++++++++++++---- tests/refine/test_samvg.py | 14 +++++++++----- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index acbb0450..c4818b15 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -1817,13 +1817,15 @@ def _accept_text_layers( def _accepted_fit( - svg: str, image: Image.Image, *, rasterize, steps: int + svg: str, image: Image.Image, *, rasterize, steps: int, learn_alpha: bool = False ) -> tuple[str, Image.Image]: """Keep a differentiable fit only when the actual SVG renderer improves.""" from vectrify.refine.paths import fit_filled_svg_bounded before = _render_svg(svg, image, rasterize) - fitted = fit_filled_svg_bounded(svg, image, rasterize=rasterize, steps=steps) + fitted = fit_filled_svg_bounded( + svg, image, rasterize=rasterize, steps=steps, learn_alpha=learn_alpha + ) after = _render_svg(fitted, image, rasterize) if _mse(image, after) <= _mse(image, before): return fitted, after @@ -1841,12 +1843,17 @@ def vectorize_svg( max_layers: int = 512, segments: int = 16, max_side: int | None = SAMVG_MAX_SIDE, + learn_alpha: bool = False, + curvature_threshold: float | None = None, + maximum_segments: int = 2048, ) -> str: """Run SAMVG's two 500-step optimise-and-recover phases. ``rasterize`` is the format backend's renderer, used solely to form the residual map after the first pass. The actual differentiable fit is the built-in filled-path optimiser so SAMVG has no external renderer dependency. + ``learn_alpha`` and ``curvature_threshold`` select the dissertation's + SAMVG+alpha and SAMVG+var representation variations, respectively. """ image = image.convert("RGB") runtime = _sam_runtime() @@ -1866,9 +1873,15 @@ def vectorize_svg( layers, segments, hybrid_strokes=False, + curvature_threshold=curvature_threshold, + maximum_segments=maximum_segments, ) first, first_render = _accepted_fit( - initial, image, rasterize=rasterize, steps=steps + initial, + image, + rasterize=rasterize, + steps=steps, + learn_alpha=learn_alpha, ) points = residual_prompt_points(image, first_render) added = filter_by_impact( @@ -1891,10 +1904,18 @@ def vectorize_svg( len(added), ) final, final_render = _accepted_fit( - _append_layers(first, added, segments, hybrid_strokes=False), + _append_layers( + first, + added, + segments, + hybrid_strokes=False, + curvature_threshold=curvature_threshold, + maximum_segments=maximum_segments, + ), image, rasterize=rasterize, steps=steps, + learn_alpha=learn_alpha, ) # A locally accepted second fit can still be worse than the first fit # if its residual additions were harmful. The public two-phase result diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index 607dfa02..b5e2d3e1 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -104,9 +104,10 @@ def generate(self, **kwargs): def test_accepted_fit_uses_bounded_fill_coordinate_descent(monkeypatch): seen = {} - def bounded(svg, image, *, rasterize, steps): + def bounded(svg, image, *, rasterize, steps, learn_alpha): seen["image"] = image.size seen["steps"] = steps + seen["learn_alpha"] = learn_alpha assert rasterize is not None return svg @@ -121,7 +122,7 @@ def bounded(svg, image, *, rasterize, steps): assert fitted == svg assert rendered.size == image.size - assert seen == {"image": (16, 16), "steps": 7} + assert seen == {"image": (16, 16), "steps": 7, "learn_alpha": False} def test_vectorize_svg_runs_a_second_residual_recovery_phase(monkeypatch): @@ -145,7 +146,7 @@ def test_vectorize_svg_runs_a_second_residual_recovery_phase(monkeypatch): lambda _image, _masks, **kwargs: [*kwargs["existing"], added_layer], ) - def accepted(svg, _image, *, rasterize, steps): + def accepted(svg, _image, *, rasterize, steps, learn_alpha): assert rasterize is not None calls.append( ( @@ -154,14 +155,17 @@ def accepted(svg, _image, *, rasterize, steps): for element in ET.fromstring(svg).iter() ), steps, + learn_alpha, ) ) return svg, image monkeypatch.setattr(samvg, "_accepted_fit", accepted) - result = samvg.vectorize_svg(image, rasterize=SvgPlugin().rasterize, steps=3) + result = samvg.vectorize_svg( + image, rasterize=SvgPlugin().rasterize, steps=3, learn_alpha=True + ) - assert calls == [(1, 3), (2, 3)] + assert calls == [(1, 3, True), (2, 3, True)] root = ET.fromstring(result) paths = list(root.findall("{http://www.w3.org/2000/svg}path")) assert len(paths) == 2 From 56de1bae1ee6ae1afd61200311dc7875dc4f334b Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Tue, 25 Aug 2026 21:38:33 +0200 Subject: [PATCH 48/57] perf: batch sparse fill replay --- src/vectrify/refine/paths.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index b390a761..c97e5f10 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1710,6 +1710,18 @@ def rasterise_simple( ) ] + def sparse_backward_batch_size(tile_width: int, tile_height: int) -> int: + """Bound the live tile-local autograd graph while filling the GPU. + + Sparse replay never needs a full-canvas alpha stack, but it does keep + the coverage graph for one backward batch alive. A fixed 16-path + batch underutilises CUDA for SAMVG's common 32--64px tiles; allowing + up to 64 such paths is still smaller than the former 16 large-tile + batches. The tile-area budget preserves that memory bound for large + shapes without changing the rendered image or its derivative. + """ + return max(1, min(64, (1 << 20) // max(1, tile_width * tile_height))) + def rasterise_multi(index: int, path: list[Any]) -> Any: # Large paths use fixed conservative candidate tiles. Every tile # sees all contours that can cross one of its horizontal rays, while @@ -2137,10 +2149,14 @@ def sparse_layer_loss( # batch more equal-size paths than the legacy dense replay. # This reduces native coverage launches without increasing the # full-canvas memory footprint. - for offset in range(0, len(items), 16): + batch_size = sparse_backward_batch_size(tile_width, tile_height) + for offset in range(0, len(items), batch_size): loss = torch.zeros((), device=device) for index, alpha, left, top in rasterise_simple_tiles( - fill_rule, tile_width, tile_height, items[offset : offset + 16] + fill_rule, + tile_width, + tile_height, + items[offset : offset + batch_size], ): loss = loss + sparse_layer_loss(index, alpha, left, top) loss.backward() From 18df891f790ebfd7ea4f40fa977783bdb19397bf Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 01:16:31 +0200 Subject: [PATCH 49/57] fix: bound sparse replay batches --- src/vectrify/refine/paths.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index c97e5f10..3dfeeba9 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1715,12 +1715,13 @@ def sparse_backward_batch_size(tile_width: int, tile_height: int) -> int: Sparse replay never needs a full-canvas alpha stack, but it does keep the coverage graph for one backward batch alive. A fixed 16-path - batch underutilises CUDA for SAMVG's common 32--64px tiles; allowing - up to 64 such paths is still smaller than the former 16 large-tile - batches. The tile-area budget preserves that memory bound for large - shapes without changing the rendered image or its derivative. + batch underutilises CUDA for SAMVG's common 32--64px tiles, but a + recovery pass can create a much larger equal-tile group than the + initial seed. Retain the proven 16-path graph cap and apply the + tile-area budget beneath it. This bounds peak memory for every + document without changing the rendered image or its derivative. """ - return max(1, min(64, (1 << 20) // max(1, tile_width * tile_height))) + return max(1, min(16, (1 << 20) // max(1, tile_width * tile_height))) def rasterise_multi(index: int, path: list[Any]) -> Any: # Large paths use fixed conservative candidate tiles. Every tile From 399962f360283a0c30f6e89ef686a9c6322d264c Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 06:01:17 +0200 Subject: [PATCH 50/57] fix: normalize SAMVG Xing loss --- src/vectrify/refine/paths.py | 30 +++--------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 3dfeeba9..5f24bc40 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1558,21 +1558,6 @@ def pad_storage_control(control: Any) -> Any: lr=color_learning_rate, fused=device == "cuda", ) - # The dissertation averages Xing within each contour then sums contours. - # Keep that weighting while evaluating the 413 cat contours in one CUDA - # expression rather than launching one tiny graph for each. - xing_contour_weights = torch.cat( - [ - torch.full( - (len(control),), - 1 / len(control), - dtype=goal.dtype, - device=device, - ) - for path in controls - for control in path - ] - ) def close_contours() -> None: """Restore the shared joins of every traced closed Bezier contour. @@ -2025,11 +2010,7 @@ def refresh_simple_tiles() -> None: else _composite_opaque_fills(alpha_stack, color_storage, under) ) loss = ((rendered - goal) ** 2).mean() - loss = ( - loss - + xing_weight - * (_xing_penalties(all_controls) * xing_contour_weights).sum() - ) + loss = loss + xing_weight * _xing_loss(all_controls) loss.backward() point_optimizer.step() colour_optimizer.step() @@ -2170,10 +2151,7 @@ def sparse_layer_loss( if index not in simple_indices: alpha = rasterise_multi(index, path) sparse_layer_loss(index, alpha, 0, 0).backward() - ( - xing_weight - * (_xing_penalties(all_controls) * xing_contour_weights).sum() - ).backward() + (xing_weight * _xing_loss(all_controls)).backward() point_optimizer.step() colour_optimizer.step() close_contours() @@ -2308,9 +2286,7 @@ def layer_loss( index, rasterise_multi(index, path), ).backward() - ( - xing_weight * (_xing_penalties(all_controls) * xing_contour_weights).sum() - ).backward() + (xing_weight * _xing_loss(all_controls)).backward() point_optimizer.step() colour_optimizer.step() close_contours() From 98d3e892fa38df2adb5379f964400adda935ba70 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 06:10:52 +0200 Subject: [PATCH 51/57] fix: bound sparse replay compiler memory --- src/vectrify/refine/paths.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vectrify/refine/paths.py b/src/vectrify/refine/paths.py index 5f24bc40..812c7ac9 100644 --- a/src/vectrify/refine/paths.py +++ b/src/vectrify/refine/paths.py @@ -1675,7 +1675,12 @@ def rasterise_simple_tiles( samples=samples_for(tile_width, tile_height), subpixels=subpixels, fuse=False, - dynamic_fuse=len(items) >= 4, + # Sparse replay keeps this graph alive through the layer's + # backward pass. Torch's dynamic compiler can specialise one + # large tile batch into an unbounded graph here; eager chunking + # has the same coverage/gradient while retaining the documented + # tile-local memory bound. + dynamic_fuse=False, ) return [ (index, alpha, left, top) From d7c4a6c5c76d2e9a41b23171ed9f3514935ad7b6 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 06:28:54 +0200 Subject: [PATCH 52/57] fix: refit visible SAMVG seed colours --- src/vectrify/refine/samvg.py | 12 +++++++++++- tests/refine/test_samvg.py | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index c4818b15..37998292 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -1127,7 +1127,12 @@ def retrieve_layers( len(prompted), len(recovered), ) - return recovered + # Mask selection intentionally scores the initially painted colours: that + # is the paper's greedy impact procedure. Once painter order is fixed, + # however, a lower layer should be coloured from only the pixels it still + # exposes. This is the least-squares fill for the emitted seed and does + # not alter its accepted masks, ordering, or coverage prompts. + return recolour_visible_layers(image, recovered) def _loops(mask: np.ndarray) -> list[list[tuple[float, float]]]: @@ -1683,6 +1688,11 @@ def generate_svg( max_side=max_side, ) ) + # ``retrieve_layers`` has already done this for the normal SAM path. Do + # it here too for caller-supplied masks, which otherwise would export + # broad lower fills coloured by pixels that later paths hide. + if masks is not None: + layers = recolour_visible_layers(image, layers) paths = [] for layer in layers: for attributes in _layer_svg_attributes( diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index b5e2d3e1..c359076d 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -606,6 +606,28 @@ def test_generate_svg_creates_editable_layered_paths_from_supplied_masks(): assert paths[0].get("fill") == "#1482dc" +def test_generate_svg_refits_each_visible_fill_colour_after_mask_selection(): + pixels = np.full((12, 12, 3), (220, 30, 30), dtype=np.uint8) + pixels[4:8, 4:8] = (20, 40, 230) + image = Image.fromarray(pixels) + outer = np.ones((12, 12), dtype=bool) + inner = np.zeros((12, 12), dtype=bool) + inner[4:8, 4:8] = True + + root = ET.fromstring( + generate_svg( + image, + [outer, inner], + min_pixels=1, + min_impact=0, + ocr=False, + ) + ) + paths = list(root.findall("{http://www.w3.org/2000/svg}path")) + + assert [path.get("fill") for path in paths] == ["#dc1e1e", "#1428e6"] + + def test_thin_single_contour_mask_is_emitted_as_a_round_stroke(): image = Image.new("RGB", (12, 32), "white") pixels = np.asarray(image).copy() From 6360f46c5064208e7310636b02d45c45c85b4841 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 06:29:45 +0200 Subject: [PATCH 53/57] fix: avoid duplicate SAMVG trace samples --- src/vectrify/refine/samvg.py | 5 ++++- tests/refine/test_samvg.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 37998292..6ced6634 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -1331,7 +1331,10 @@ def _cubic_loop( np.arange(first, second + 1 if second >= first else second + size + 1) % size ) - sample = np.vstack((points[indices], points[second])) + # ``indices`` already includes the endpoint. Repeating it adds an + # artificial least-squares weight at every selected corner and bends + # each fitted cubic toward its end point rather than the contour data. + sample = points[indices] control_a, control_b = _fit_cubic(sample) end = points[second] parts.append( diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index c359076d..a28a9a0f 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -749,6 +749,32 @@ def test_cubic_fit_reparameterises_nonuniform_curve_samples(): ) +def test_fixed_cubic_tracing_does_not_duplicate_each_curve_endpoint(monkeypatch): + loop = [ + (0.0, 0.0), + (1.0, 0.0), + (2.0, 0.0), + (2.0, 1.0), + (2.0, 2.0), + (1.0, 2.0), + (0.0, 2.0), + (0.0, 1.0), + ] + samples = [] + monkeypatch.setattr(samvg, "_corners", lambda *_args: [0, 2, 4, 6]) + + def fit(points): + samples.append(points.copy()) + return points[0], points[-1] + + monkeypatch.setattr(samvg, "_fit_cubic", fit) + + samvg._cubic_loop(loop, segments=4) + + assert [len(points) for points in samples] == [3, 3, 3, 3] + assert all(not np.array_equal(points[-1], points[-2]) for points in samples) + + def test_sam_input_cap_restores_binary_masks_to_the_original_canvas(): image = Image.new("RGB", (100, 50), "white") From a866a33f533850d10fd762a32335b664c9ed6013 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 06:34:28 +0200 Subject: [PATCH 54/57] fix: disable SAMVG seeds by default --- README.md | 17 ++++++++--------- src/vectrify/cli.py | 4 ++-- src/vectrify/vector/runner.py | 4 ++-- tests/test_cli.py | 11 +++++------ 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 93c2a0c8..05169791 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ vectrify photo.jpg -o sketch.svg --seeds 10 --epochs 4 \ --max-wall-seconds 1800 vectrify mascot.png -o mascot.svg --segment-count 12 # tiles/local elites (default: 8) -# Skip the default segmentation-derived SVG seed (SVG only) -vectrify artwork.png -o artwork.svg --no-samvg-seed +# Add the optional segmentation-derived SVG seed (SVG only) +vectrify artwork.png -o artwork.svg --samvg-seed # Choose a provider, model, or scorer explicitly vectrify input.png --provider anthropic --model MODEL_NAME @@ -109,14 +109,13 @@ by default; add `--save-heatmap` for perceptual difference maps. ## SAMVG-inspired seed -One native, segmentation-first SVG candidate is added to every SVG run without -reducing the configured LLM seed count. It uses SAM ViT-H by default, retains -masks only when they materially improve a flat-colour reconstruction of the -target, and traces the retained masks into editable layered SVG paths. Set +With `--samvg-seed`, Vectrify adds one native, segmentation-first SVG candidate +without reducing the configured LLM seed count. It uses SAM ViT-H by default, +retains masks only when they materially improve a flat-colour reconstruction of +the target, and traces the retained masks into editable layered SVG paths. Set `VECTRIFY_SAMVG_MODEL=facebook/sam-vit-base` for the smaller checkpoint. It is -inspired by SAMVG, not an installation of the unreleased research code. Use -`--no-samvg-seed` to skip it; the feature is currently available for SVG output -only. +inspired by SAMVG, not an installation of the unreleased research code and is +off by default. SAM inputs default to a 1024px maximum side, the model's native encoder size; the returned masks are restored to the target's original canvas before tracing. diff --git a/src/vectrify/cli.py b/src/vectrify/cli.py index b61c0fd6..44689f4f 100644 --- a/src/vectrify/cli.py +++ b/src/vectrify/cli.py @@ -199,11 +199,11 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace: g_search.add_argument( "--samvg-seed", action=argparse.BooleanOptionalAction, - default=True, + default=False, help="Add one SAMVG-inspired SVG seed made from automatic SAM masks, " "impact filtering, contour tracing, and Torch OCR. Requires " "vectrify[samvg] and " - "is available for SVG output only. Default: on", + "is available for SVG output only. Default: off", ) g_epoch = parser.add_argument_group( diff --git a/src/vectrify/vector/runner.py b/src/vectrify/vector/runner.py index a6559758..50312d64 100644 --- a/src/vectrify/vector/runner.py +++ b/src/vectrify/vector/runner.py @@ -112,7 +112,7 @@ class VectorSearchConfig: vision_model: str = DEFAULT_VISION_MODEL auto_crop: bool = True segment_count: int = 8 - samvg_seed: bool = True + samvg_seed: bool = False dry_run: bool = False @@ -316,7 +316,7 @@ def run_vector_search( vision_model: str = DEFAULT_VISION_MODEL, # for the front evaluator auto_crop: bool = True, segment_count: int = 8, - samvg_seed: bool = True, + samvg_seed: bool = False, dry_run: bool = False, dry_run_parameters: Mapping[str, Any] | None = None, stats: "SearchStats | None" = None, diff --git a/tests/test_cli.py b/tests/test_cli.py index 82f30e0e..57bf04c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -61,10 +61,9 @@ def test_negative_seeds_raises(): parse_args(["img.png", "--seeds", "-1"]) -def test_seeds_zero_uses_the_default_samvg_seed_or_requires_resume_when_disabled(): - assert parse_args(["img.png", "--seeds", "0"]).seeds == 0 +def test_seeds_zero_requires_resume_or_an_explicit_samvg_seed(): with pytest.raises(SystemExit): - parse_args(["img.png", "--seeds", "0", "--no-samvg-seed"]) + parse_args(["img.png", "--seeds", "0"]) assert parse_args(["img.png", "--seeds", "0", "--resume"]).seeds == 0 @@ -82,9 +81,9 @@ def test_samvg_seed_allows_a_local_only_run(): assert args.seeds == 0 -def test_samvg_seed_is_enabled_by_default_and_can_be_disabled(): - assert parse_args(["img.png"]).samvg_seed is True - assert parse_args(["img.png", "--no-samvg-seed"]).samvg_seed is False +def test_samvg_seed_is_disabled_by_default_and_can_be_enabled(): + assert parse_args(["img.png"]).samvg_seed is False + assert parse_args(["img.png", "--samvg-seed"]).samvg_seed is True # Defaults are pinned as literals so a change to any default is a visible, From 77f201d7931eee66bb872a77f0f3a18facdf023d Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 06:35:13 +0200 Subject: [PATCH 55/57] feat: group SAMVG seed help --- src/vectrify/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vectrify/cli.py b/src/vectrify/cli.py index 44689f4f..0b3d09ed 100644 --- a/src/vectrify/cli.py +++ b/src/vectrify/cli.py @@ -196,7 +196,8 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace: metavar="N", help="Edge-aware Voronoi masks retained as local elites. Default: 8", ) - g_search.add_argument( + g_samvg = parser.add_argument_group("SAMVG seed") + g_samvg.add_argument( "--samvg-seed", action=argparse.BooleanOptionalAction, default=False, From 946204dbd3ef5dd12b1d864b44cbbf56150ae7aa Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 06:44:04 +0200 Subject: [PATCH 56/57] feat: expose SAMVG seed controls --- src/vectrify/cli.py | 91 +++++++++++++++++++++++++++++++++++ src/vectrify/main.py | 10 ++++ src/vectrify/refine/samvg.py | 37 ++++++++++---- src/vectrify/vector/runner.py | 57 +++++++++++++++++++++- tests/refine/test_samvg.py | 4 +- tests/test_cli.py | 37 ++++++++++++++ 6 files changed, 224 insertions(+), 12 deletions(-) diff --git a/src/vectrify/cli.py b/src/vectrify/cli.py index 0b3d09ed..e66236ec 100644 --- a/src/vectrify/cli.py +++ b/src/vectrify/cli.py @@ -4,6 +4,11 @@ from importlib.metadata import version as _pkg_version from vectrify.formats import FORMAT_NAMES +from vectrify.refine.samvg import ( + SAMVG_MAX_SIDE, + SAMVG_MODEL, + SAMVG_POINTS_PER_BATCH, +) from vectrify.score import ScorerType from vectrify.score.vision import DEFAULT_VISION_MODEL @@ -206,6 +211,82 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace: "vectrify[samvg] and " "is available for SVG output only. Default: off", ) + g_samvg.add_argument( + "--samvg-model", + default=SAMVG_MODEL, + metavar="HF_REPO", + help="HuggingFace SAM checkpoint used for the seed. " + f"Default: {SAMVG_MODEL}", + ) + g_samvg.add_argument( + "--samvg-max-side", + type=int, + default=SAMVG_MAX_SIDE, + metavar="PX", + help="Maximum long side passed to SAM before masks are restored to " + "the output canvas. " + f"Default: {SAMVG_MAX_SIDE}", + ) + g_samvg.add_argument( + "--samvg-points-per-batch", + type=int, + default=SAMVG_POINTS_PER_BATCH, + metavar="N", + help="SAM decoder prompts per GPU batch; this does not change the " + "32x32 automatic prompt grid. " + f"Default: {SAMVG_POINTS_PER_BATCH}", + ) + g_samvg.add_argument( + "--samvg-min-pixels", + type=int, + default=32, + metavar="N", + help="Discard connected mask components smaller than N pixels before " + "tracing. Default: 32", + ) + g_samvg.add_argument( + "--samvg-min-impact", + type=float, + default=3e-6, + metavar="MSE", + help="Minimum whole-mask reconstruction improvement required for " + "retention. Default: 3e-6", + ) + g_samvg.add_argument( + "--samvg-max-layers", + type=int, + default=512, + metavar="N", + help="Maximum retained SAM masks in each seed stage. Default: 512", + ) + g_samvg.add_argument( + "--samvg-segments", + type=int, + default=16, + metavar="N", + help="Fixed cubic Bézier segments per traced contour. Default: 16", + ) + g_samvg.add_argument( + "--samvg-fill-holes", + action=argparse.BooleanOptionalAction, + default=True, + help="Fill only sub-threshold enclosed mask holes before tracing. " + "Default: on", + ) + g_samvg.add_argument( + "--samvg-hybrid-strokes", + action=argparse.BooleanOptionalAction, + default=True, + help="Emit conservative centreline strokes for thin seed masks. " + "Default: on", + ) + g_samvg.add_argument( + "--samvg-ocr", + action=argparse.BooleanOptionalAction, + default=True, + help="Run optional OCR and retain pixel-verified editable text. " + "Default: on", + ) g_epoch = parser.add_argument_group( "Epoch control. Any convergence criterion that is set can end an epoch " @@ -485,6 +566,16 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace: raise SystemExit("Error: --workers and --pool-size must be > 0") if ns.segment_count <= 0: raise SystemExit("Error: --segment-count must be > 0") + if ( + ns.samvg_max_side <= 0 + or ns.samvg_points_per_batch <= 0 + or ns.samvg_min_pixels <= 0 + or ns.samvg_max_layers <= 0 + or ns.samvg_segments <= 0 + ): + raise SystemExit("Error: SAMVG integer controls must be > 0") + if ns.samvg_min_impact < 0: + raise SystemExit("Error: --samvg-min-impact must be >= 0") if ns.resolution <= 0: raise SystemExit("Error: --resolution must be > 0") if ns.resolution_llm <= 0: diff --git a/src/vectrify/main.py b/src/vectrify/main.py index 7e6c33dc..48a102ed 100755 --- a/src/vectrify/main.py +++ b/src/vectrify/main.py @@ -153,6 +153,16 @@ def main(): vision_model=args.vision_model, segment_count=args.segment_count, samvg_seed=args.samvg_seed, + samvg_model=args.samvg_model, + samvg_max_side=args.samvg_max_side, + samvg_points_per_batch=args.samvg_points_per_batch, + samvg_min_pixels=args.samvg_min_pixels, + samvg_min_impact=args.samvg_min_impact, + samvg_max_layers=args.samvg_max_layers, + samvg_segments=args.samvg_segments, + samvg_fill_holes=args.samvg_fill_holes, + samvg_hybrid_strokes=args.samvg_hybrid_strokes, + samvg_ocr=args.samvg_ocr, auto_crop=args.auto_crop, dry_run=args.dry_run, dry_run_parameters=vars(args), diff --git a/src/vectrify/refine/samvg.py b/src/vectrify/refine/samvg.py index 6ced6634..7e22b4c3 100644 --- a/src/vectrify/refine/samvg.py +++ b/src/vectrify/refine/samvg.py @@ -469,7 +469,7 @@ class _SamRuntime: embedding_size: tuple[int, int] | None = None -def _sam_runtime() -> _SamRuntime: +def _sam_runtime(*, model: str = SAMVG_MODEL) -> _SamRuntime: """Load SAM once, in half precision when CUDA is available.""" try: import torch @@ -478,13 +478,13 @@ def _sam_runtime() -> _SamRuntime: raise ImportError( "SAMVG requires the samvg extra. Install 'vectrify[samvg]'." ) from exc - options: dict[str, Any] = {"model": SAMVG_MODEL, "device": 0} + options: dict[str, Any] = {"model": model, "device": 0} if torch.cuda.is_available(): options["dtype"] = torch.float16 generator = pipeline("mask-generation", **options) log.info( "SAMVG automatic masks: %s on %s (%s).", - SAMVG_MODEL, + model, generator.device, "fp16" if torch.cuda.is_available() else "fp32", ) @@ -691,6 +691,7 @@ def automatic_masks( image: Image.Image, *, max_side: int | None = SAMVG_MAX_SIDE, + points_per_batch: int = SAMVG_POINTS_PER_BATCH, _runtime: _SamRuntime | None = None, ) -> list[np.ndarray]: """Retrieve SAM AMG masks with the thesis grid, optionally size-capped.""" @@ -752,7 +753,7 @@ def collect(points_per_batch: int) -> list[np.ndarray]: torch.cat(all_masks), torch.cat(all_scores), torch.cat(all_boxes) ) - collected = collect(SAMVG_POINTS_PER_BATCH) + collected = collect(points_per_batch) return [_restore_mask(mask, original_size) for mask in collected] @@ -1021,6 +1022,7 @@ def prompted_masks( points: list[tuple[int, int]], *, max_side: int | None = SAMVG_MAX_SIDE, + points_per_batch: int = SAMVG_POINTS_PER_BATCH, _runtime: _SamRuntime | None = None, ) -> list[np.ndarray]: """Prompt SAM at centres and return all three masks per point. @@ -1046,8 +1048,8 @@ def prompted_masks( runtime.processor = SamProcessor(runtime.generator.image_processor) try: output_masks = [] - for start in range(0, len(points), SAMVG_POINTS_PER_BATCH): - batch = points[start : start + SAMVG_POINTS_PER_BATCH] + for start in range(0, len(points), points_per_batch): + batch = points[start : start + points_per_batch] input_points = [[[list(point)] for point in batch]] inputs = runtime.processor( images=image, input_points=input_points, return_tensors="pt" @@ -1089,14 +1091,21 @@ def retrieve_layers( max_layers: int = 512, fill_holes: bool = True, max_side: int | None = SAMVG_MAX_SIDE, + model: str = SAMVG_MODEL, + points_per_batch: int = SAMVG_POINTS_PER_BATCH, _runtime: _SamRuntime | None = None, ) -> list[MaskLayer]: """Run SAMVG's automatic-mask, coverage-prompt, filter sequence.""" image = image.convert("RGB") runtime = _runtime if masks is None: - runtime = runtime or _sam_runtime() - initial = automatic_masks(image, max_side=max_side, _runtime=runtime) + runtime = runtime or _sam_runtime(model=model) + initial = automatic_masks( + image, + max_side=max_side, + points_per_batch=points_per_batch, + _runtime=runtime, + ) else: initial = masks layers = filter_by_impact( @@ -1108,7 +1117,13 @@ def retrieve_layers( fill_holes=fill_holes, ) points = coverage_prompt_points(layers, (image.height, image.width)) - prompted = prompted_masks(image, points, max_side=max_side, _runtime=runtime) + prompted = prompted_masks( + image, + points, + max_side=max_side, + points_per_batch=points_per_batch, + _runtime=runtime, + ) recovered = filter_by_impact( image, prompted, @@ -1668,6 +1683,8 @@ def generate_svg( hybrid_strokes: bool = True, ocr: bool = True, max_side: int | None = SAMVG_MAX_SIDE, + model: str = SAMVG_MODEL, + points_per_batch: int = SAMVG_POINTS_PER_BATCH, rasterize: Callable[[str, int, int], bytes] | None = None, ) -> str: """Generate SAMVG's traced, pre-optimisation SVG from a target image.""" @@ -1689,6 +1706,8 @@ def generate_svg( max_layers=max_layers, fill_holes=fill_holes, max_side=max_side, + model=model, + points_per_batch=points_per_batch, ) ) # ``retrieve_layers`` has already done this for the normal SAM path. Do diff --git a/src/vectrify/vector/runner.py b/src/vectrify/vector/runner.py index 50312d64..9db5507b 100644 --- a/src/vectrify/vector/runner.py +++ b/src/vectrify/vector/runner.py @@ -39,7 +39,12 @@ resize_long_side, ) from vectrify.llm.models import api_key_env -from vectrify.refine.samvg import generate_svg +from vectrify.refine.samvg import ( + SAMVG_MAX_SIDE, + SAMVG_MODEL, + SAMVG_POINTS_PER_BATCH, + generate_svg, +) from vectrify.score import ScorerType, choose_scorer from vectrify.score.base import DEFAULT_CONFIG from vectrify.score.compare import compare, prepare @@ -113,6 +118,16 @@ class VectorSearchConfig: auto_crop: bool = True segment_count: int = 8 samvg_seed: bool = False + samvg_model: str = SAMVG_MODEL + samvg_max_side: int = SAMVG_MAX_SIDE + samvg_points_per_batch: int = SAMVG_POINTS_PER_BATCH + samvg_min_pixels: int = 32 + samvg_min_impact: float = 3e-6 + samvg_max_layers: int = 512 + samvg_segments: int = 16 + samvg_fill_holes: bool = True + samvg_hybrid_strokes: bool = True + samvg_ocr: bool = True dry_run: bool = False @@ -317,6 +332,16 @@ def run_vector_search( auto_crop: bool = True, segment_count: int = 8, samvg_seed: bool = False, + samvg_model: str = SAMVG_MODEL, + samvg_max_side: int = SAMVG_MAX_SIDE, + samvg_points_per_batch: int = SAMVG_POINTS_PER_BATCH, + samvg_min_pixels: int = 32, + samvg_min_impact: float = 3e-6, + samvg_max_layers: int = 512, + samvg_segments: int = 16, + samvg_fill_holes: bool = True, + samvg_hybrid_strokes: bool = True, + samvg_ocr: bool = True, dry_run: bool = False, dry_run_parameters: Mapping[str, Any] | None = None, stats: "SearchStats | None" = None, @@ -351,6 +376,16 @@ def run_vector_search( auto_crop=auto_crop, segment_count=segment_count, samvg_seed=samvg_seed, + samvg_model=samvg_model, + samvg_max_side=samvg_max_side, + samvg_points_per_batch=samvg_points_per_batch, + samvg_min_pixels=samvg_min_pixels, + samvg_min_impact=samvg_min_impact, + samvg_max_layers=samvg_max_layers, + samvg_segments=samvg_segments, + samvg_fill_holes=samvg_fill_holes, + samvg_hybrid_strokes=samvg_hybrid_strokes, + samvg_ocr=samvg_ocr, dry_run=dry_run, ) resolution_llm = config.resolution_llm @@ -377,6 +412,16 @@ def run_vector_search( auto_crop = config.auto_crop segment_count = config.segment_count samvg_seed = config.samvg_seed + samvg_model = config.samvg_model + samvg_max_side = config.samvg_max_side + samvg_points_per_batch = config.samvg_points_per_batch + samvg_min_pixels = config.samvg_min_pixels + samvg_min_impact = config.samvg_min_impact + samvg_max_layers = config.samvg_max_layers + samvg_segments = config.samvg_segments + samvg_fill_holes = config.samvg_fill_holes + samvg_hybrid_strokes = config.samvg_hybrid_strokes + samvg_ocr = config.samvg_ocr dry_run = config.dry_run epoch_seeds = resolve_seeds(seeds) @@ -481,6 +526,16 @@ def run_vector_search( content = format_plugin.extract_from_llm( generate_svg( original_img, + min_pixels=samvg_min_pixels, + min_impact=samvg_min_impact, + max_layers=samvg_max_layers, + segments=samvg_segments, + fill_holes=samvg_fill_holes, + hybrid_strokes=samvg_hybrid_strokes, + ocr=samvg_ocr, + max_side=samvg_max_side, + model=samvg_model, + points_per_batch=samvg_points_per_batch, rasterize=lambda svg, width, height: format_plugin.rasterize( svg, out_w=width, out_h=height ), diff --git a/tests/refine/test_samvg.py b/tests/refine/test_samvg.py index a28a9a0f..459ffb94 100644 --- a/tests/refine/test_samvg.py +++ b/tests/refine/test_samvg.py @@ -134,7 +134,7 @@ def test_vectorize_svg_runs_a_second_residual_recovery_phase(monkeypatch): initial_layer = MaskLayer(base, (10, 20, 30), 1.0) added_layer = MaskLayer(added, (40, 50, 60), 1.0) calls = [] - monkeypatch.setattr(samvg, "_sam_runtime", lambda: object()) + monkeypatch.setattr(samvg, "_sam_runtime", lambda **_kwargs: object()) monkeypatch.setattr( samvg, "retrieve_layers", lambda *_args, **_kwargs: [initial_layer] ) @@ -180,7 +180,7 @@ def test_vectorize_svg_rejects_a_residual_phase_that_regresses_first_fit(monkeyp added[10:14, 10:14] = True initial_layer = MaskLayer(base, (10, 20, 30), 1.0) added_layer = MaskLayer(added, (40, 50, 60), 1.0) - monkeypatch.setattr(samvg, "_sam_runtime", lambda: object()) + monkeypatch.setattr(samvg, "_sam_runtime", lambda **_kwargs: object()) monkeypatch.setattr( samvg, "retrieve_layers", lambda *_args, **_kwargs: [initial_layer] ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 57bf04c6..6713f873 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -86,6 +86,43 @@ def test_samvg_seed_is_disabled_by_default_and_can_be_enabled(): assert parse_args(["img.png", "--samvg-seed"]).samvg_seed is True +def test_samvg_seed_knobs_are_parsed_independently_of_the_opt_in_flag(): + args = parse_args( + [ + "img.png", + "--samvg-model", + "facebook/sam-vit-base", + "--samvg-max-side", + "768", + "--samvg-points-per-batch", + "96", + "--samvg-min-pixels", + "48", + "--samvg-min-impact", + "0.00002", + "--samvg-max-layers", + "128", + "--samvg-segments", + "12", + "--no-samvg-fill-holes", + "--no-samvg-hybrid-strokes", + "--no-samvg-ocr", + ] + ) + + assert args.samvg_seed is False + assert args.samvg_model == "facebook/sam-vit-base" + assert args.samvg_max_side == 768 + assert args.samvg_points_per_batch == 96 + assert args.samvg_min_pixels == 48 + assert args.samvg_min_impact == 0.00002 + assert args.samvg_max_layers == 128 + assert args.samvg_segments == 12 + assert args.samvg_fill_holes is False + assert args.samvg_hybrid_strokes is False + assert args.samvg_ocr is False + + # Defaults are pinned as literals so a change to any default is a visible, # deliberate edit here rather than silently tracking the constant. def test_defaults_pinned(): From 7ec2617ae4bee1dc73034ff6d92c740aa1e6f7b6 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Wed, 26 Aug 2026 07:01:02 +0200 Subject: [PATCH 57/57] style: format SAMVG tooling --- scripts/bench_samvg_two_phase.py | 4 +--- src/vectrify/cli.py | 12 ++++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/scripts/bench_samvg_two_phase.py b/scripts/bench_samvg_two_phase.py index c0aacbc3..614fdc4e 100644 --- a/scripts/bench_samvg_two_phase.py +++ b/scripts/bench_samvg_two_phase.py @@ -114,9 +114,7 @@ def run_target( (destination / "first-seed.svg").write_text(initial) if seed_only: seed_render = _render_svg(initial, target, plugin.rasterize) - mask_canvas, _coverage = _render_layers( - (target.height, target.width), layers - ) + mask_canvas, _coverage = _render_layers((target.height, target.width), layers) stages = [ ("target", target, None), ("mask-canvas", Image.fromarray(mask_canvas), None), diff --git a/src/vectrify/cli.py b/src/vectrify/cli.py index e66236ec..50e1c584 100644 --- a/src/vectrify/cli.py +++ b/src/vectrify/cli.py @@ -215,8 +215,7 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace: "--samvg-model", default=SAMVG_MODEL, metavar="HF_REPO", - help="HuggingFace SAM checkpoint used for the seed. " - f"Default: {SAMVG_MODEL}", + help=f"HuggingFace SAM checkpoint used for the seed. Default: {SAMVG_MODEL}", ) g_samvg.add_argument( "--samvg-max-side", @@ -270,22 +269,19 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace: "--samvg-fill-holes", action=argparse.BooleanOptionalAction, default=True, - help="Fill only sub-threshold enclosed mask holes before tracing. " - "Default: on", + help="Fill only sub-threshold enclosed mask holes before tracing. Default: on", ) g_samvg.add_argument( "--samvg-hybrid-strokes", action=argparse.BooleanOptionalAction, default=True, - help="Emit conservative centreline strokes for thin seed masks. " - "Default: on", + help="Emit conservative centreline strokes for thin seed masks. Default: on", ) g_samvg.add_argument( "--samvg-ocr", action=argparse.BooleanOptionalAction, default=True, - help="Run optional OCR and retain pixel-verified editable text. " - "Default: on", + help="Run optional OCR and retain pixel-verified editable text. Default: on", ) g_epoch = parser.add_argument_group(