Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/samvg-cuda.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions scripts/check_cuda_renderer.py
Original file line number Diff line number Diff line change
@@ -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 = """<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\">
<path d=\"M 2 2 C 20 0 32 12 30 30 C 10 32 0 20 2 2 Z\" fill=\"#4080c0\"/>
</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()
51 changes: 51 additions & 0 deletions tests/refine/test_samvg_regression.py
Original file line number Diff line number Diff line change
@@ -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)
Loading