diff --git a/.github/workflows/samvg-cuda.yml b/.github/workflows/samvg-cuda.yml new file mode 100644 index 0000000..32ff02e --- /dev/null +++ b/.github/workflows/samvg-cuda.yml @@ -0,0 +1,42 @@ +name: SAMVG CUDA + +on: + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +jobs: + extension: + name: Build and test CUDA renderer + runs-on: [self-hosted, linux, gpu] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install CUDA-capable test dependencies + run: uv sync --extra dev --extra samvg + + - name: Build the optional extension + run: VECTRIFY_BUILD_SAMVG_CUDA=1 uv build --wheel --no-build-isolation + + - name: Install the built wheel + run: uv pip install --reinstall --no-deps dist/*.whl + + - name: Exercise the CUDA renderer tests + run: uv run pytest tests/refine/test_filled_paths.py -q + + - name: Benchmark one deterministic renderer step + run: uv run python scripts/check_cuda_renderer.py + + - name: Preserve wheels for renderer benchmarking + uses: actions/upload-artifact@v4 + with: + name: samvg-cuda-wheel + path: dist/*.whl diff --git a/scripts/check_cuda_renderer.py b/scripts/check_cuda_renderer.py new file mode 100644 index 0000000..8731cc7 --- /dev/null +++ b/scripts/check_cuda_renderer.py @@ -0,0 +1,34 @@ +"""Run one reproducible CUDA renderer timing without downloading a model.""" + +from __future__ import annotations + +from time import perf_counter + +from PIL import Image + +from vectrify.refine import cuda_renderer +from vectrify.refine.paths import fit_filled_svg + +SVG = """ + +""" + + +def main() -> None: + import torch + + if not torch.cuda.is_available(): + raise RuntimeError("SAMVG CUDA benchmark requires a GPU runner") + if cuda_renderer._extension() is None: + raise RuntimeError("SAMVG CUDA extension was not built") + target = Image.new("RGB", (32, 32), "#4080c0") + fit_filled_svg(SVG, target, steps=1, optimisation_long_side=32) + torch.cuda.synchronize() + started = perf_counter() + fit_filled_svg(SVG, target, steps=1, optimisation_long_side=32) + torch.cuda.synchronize() + print(f"samvg-cuda renderer step: {perf_counter() - started:.4f}s") + + +if __name__ == "__main__": + main() diff --git a/tests/refine/test_samvg_regression.py b/tests/refine/test_samvg_regression.py new file mode 100644 index 0000000..9c8d21a --- /dev/null +++ b/tests/refine/test_samvg_regression.py @@ -0,0 +1,51 @@ +"""Deterministic, model-free SAMVG seed regression coverage.""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET + +import numpy as np +import pytest +from PIL import Image + +from vectrify.refine.samvg import generate_svg + + +@pytest.fixture +def two_band_target() -> tuple[Image.Image, list[np.ndarray]]: + """A fixed seed image and its segmentation masks, with no model download.""" + pixels = np.array( + [ + [[20, 30, 40]] * 6, + [[20, 30, 40]] * 6, + [[220, 210, 200]] * 6, + [[220, 210, 200]] * 6, + ], + dtype=np.uint8, + ) + upper = np.zeros((4, 6), dtype=bool) + upper[:2] = True + lower = np.zeros((4, 6), dtype=bool) + lower[2:] = True + return Image.fromarray(pixels), [upper, lower] + + +def test_fixed_seed_image_exports_two_editable_coloured_paths(two_band_target): + image, masks = two_band_target + + svg = generate_svg( + image, + masks, + min_pixels=1, + min_impact=0, + segments=4, + hybrid_strokes=False, + ocr=False, + ) + + root = ET.fromstring(svg) + paths = list(root) + assert root.attrib["viewBox"] == "0 0 6 4" + assert [path.attrib["fill"] for path in paths] == ["#141e28", "#dcd2c8"] + assert all(path.attrib["fill-rule"] == "evenodd" for path in paths) + assert all(path.attrib["d"].endswith(" Z") for path in paths)