From 140f92803aa0c673e5ee98f5f194af1dc6ec6083 Mon Sep 17 00:00:00 2001 From: Rasmus Ros Date: Mon, 24 Aug 2026 11:27:18 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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}