diff --git a/.github/workflows/extras.yml b/.github/workflows/extras.yml index 51f22d9..ec15aa1 100644 --- a/.github/workflows/extras.yml +++ b/.github/workflows/extras.yml @@ -133,3 +133,103 @@ jobs: run: | bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) ./actionlint -oneline examples/ci-visual-regression/visual-regression.yml + + # End-to-end proof of examples/ci-visual-regression/visual-regression.yml: + # materializes baseline/ + screenshots/ from the committed fixtures, then + # runs the same release-tarball + cosine-gate steps so the public run log + # shows the gate working on real images. Fixtures are synthetic UI mockups + # (regenerate with examples/ci-visual-regression/make_fixtures.py): two + # nearly identical pairs pass, and a screenshots-only settings.png is + # reported NEW instead of failing, so a healthy run stays green. + visual-regression: + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + THRESHOLD: "0.98" + MODEL_REPO: "dinov2-cpp-core/dinov2-small-gguf" + steps: + - uses: actions/checkout@v4 + + - name: Materialize baseline/ and screenshots/ from fixtures + run: | + cp -r examples/ci-visual-regression/fixtures/baseline baseline + cp -r examples/ci-visual-regression/fixtures/screenshots screenshots + find baseline screenshots -type f | sort + + - name: Download dinov2-cli release binary + run: | + TAG=$(basename "$(curl -sIL -o /dev/null -w '%{url_effective}' \ + https://github.com/espetro/dinov2.cpp/releases/latest)") + curl -LO "https://github.com/espetro/dinov2.cpp/releases/download/$TAG/dinov2-$TAG-bin-ubuntu-x64.tar.gz" + curl -LO "https://github.com/espetro/dinov2.cpp/releases/download/$TAG/SHA256SUMS.txt" + + - name: Verify checksums and unpack + run: | + TARBALL=$(ls dinov2-*-bin-ubuntu-x64.tar.gz) + grep " $TARBALL\$" SHA256SUMS.txt > check.sums + sha256sum -c check.sums + tar xzf "$TARBALL" + find . -name dinov2-cli -type f + + - name: Download GGUF weight + run: | + mkdir -p models + curl -L -o models/model.gguf \ + "https://huggingface.co/$MODEL_REPO/resolve/main/model.gguf" + + - name: Embed baseline and screenshot fixtures + run: | + CLI=$(find . -name dinov2-cli -type f | head -1) + mkdir -p emb + embed() { + args=() + while IFS= read -r f; do args+=(-i "$f"); done \ + < <(find "$1" -maxdepth 1 \( -name '*.png' -o -name '*.jpg' \) | sort) + if [ "${#args[@]}" -eq 0 ]; then + echo "no images in $1/"; return 0 + fi + "$CLI" -m models/model.gguf --preprocess crop518 --batch 8 \ + --print-embeddings "${args[@]}" > "$2" + } + embed baseline emb/baseline.jsonl + embed screenshots emb/current.jsonl + + - name: Compare embeddings (cosine >= THRESHOLD) + run: | + python3 - <<'PY' + import json, math, os, sys + + def load(path): + out = {} + for line in open(path): + r = json.loads(line) + out[os.path.basename(r["image"])] = r.get("pooled") or r["cls"] + return out + + def cosine(a, b): + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(x * x for x in b)) + return dot / (na * nb) if na and nb else 0.0 + + threshold = float(os.environ["THRESHOLD"]) + base = load("emb/baseline.jsonl") + curr = load("emb/current.jsonl") + failed = [] + for name, bvec in sorted(base.items()): + if name not in curr: + print(f"MISSING {name}: no current screenshot") + failed.append(name) + continue + s = cosine(bvec, curr[name]) + print(f"{'ok' if s >= threshold else 'FAIL'} {s:.4f} {name}") + if s < threshold: + failed.append(name) + for name in curr: + if name not in base: + print(f"NEW {name}: add to baseline/ if intentional") + if failed: + print(f"\n{len(failed)} screenshot(s) below {threshold}: {failed}") + sys.exit(1) + PY diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..0d7d280 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,71 @@ +name: pages + +# Builds the wasm demo and deploys it to GitHub Pages so index.html is +# hostable without committing a model (the page defaults to a Hugging Face +# /resolve/ URL, which sends CORS headers). +# +# One-time prereq: in repo Settings -> Pages, set Source to "GitHub Actions". +# The github-pages environment is created automatically on first deploy. +on: + push: + branches: [main] + paths: + - "wasm/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup emsdk + uses: mymindstorm/setup-emsdk@v14 + with: + version: latest + actions-cache-folder: emsdk-cache + + - name: Configure (wasm preset) + run: cmake --preset wasm + + - name: Build dinov2-wasm + run: cmake --build --preset wasm -j"$(nproc)" + + - name: Stage site + run: | + mkdir -p site + cp wasm/index.html wasm/main.js site/ + cp build-wasm/bin/dinov2-wasm.js build-wasm/bin/dinov2-wasm.wasm site/ + ls -lh site/ + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/wasm.md b/docs/wasm.md index b9c34c6..ed4598f 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -52,6 +52,19 @@ embind. For a node smoke test reconfigure with ## Running the demo +### Hosted demo (GitHub Pages) + +`.github/workflows/pages.yml` rebuilds the wasm bundle on every push to +`main` that touches `wasm/` and deploys `index.html`, `main.js` and +`dinov2-wasm.{js,wasm}` to GitHub Pages (it can also be triggered manually +via `workflow_dispatch`). One-time prerequisite: in the repo settings under +**Pages**, set **Source** to "GitHub Actions"; the `github-pages` +environment is created automatically on first deploy. The page defaults to +fetching the `dinov2-with-registers-small-gguf` model straight from Hugging +Face, so no weight needs to be committed or served. + +### Local + The demo (`wasm/index.html` + `wasm/main.js`) expects the build artifacts next to it: @@ -63,12 +76,14 @@ cd wasm && python3 -m http.server 8000 Model source, in the URL field: -- `model.gguf` (default): fetched relative to the page, so drop a GGUF next to +- `https://huggingface.co/dinov2-cpp-core/dinov2-with-registers-small-gguf/resolve/main/model.gguf` + (default): the ViT-S with registers variant, fetched straight from Hugging + Face. Register tokens give cleaner patch heatmaps. huggingface.co + `/resolve/` URLs send CORS headers (verified), so remote URLs work when the + host allows them. +- `model.gguf`: fetched relative to the page, so drop a GGUF next to `index.html`. Get one with `hf download dinov2-cpp-core/dinov2-small-gguf model.gguf --local-dir wasm` (~50 MB f16 ViT-S). -- A remote URL also works when the host sends CORS headers. huggingface.co - `/resolve/` URLs do (verified), e.g. - `https://huggingface.co/dinov2-cpp-core/dinov2-small-gguf/resolve/main/model.gguf`. Then pick two images: the page decodes them via `createImageBitmap` + canvas, encodes each (RGB8/RGBA8 in, preprocessing happens inside the library), and diff --git a/examples/ci-visual-regression/README.md b/examples/ci-visual-regression/README.md index faf7d1e..2d42848 100644 --- a/examples/ci-visual-regression/README.md +++ b/examples/ci-visual-regression/README.md @@ -4,9 +4,28 @@ A copy-paste GitHub Action that uses release `dinov2-cli` builds as a visual regression gate: screenshots from your PR are compared against committed baselines by embedding cosine similarity instead of pixel diffs. -**This is an example, not a gate on this repo.** It is not referenced by any -workflow in `.github/workflows/`; copy `visual-regression.yml` into your own -project and adapt it. +**This is an example, not a gate on this repo.** `visual-regression.yml` is +not referenced directly by `.github/workflows/`; copy it into your own +project and adapt it. It does get exercised end to end though: the +`visual-regression` job in `.github/workflows/extras.yml` (advisory, +`continue-on-error`) materializes `baseline/` and `screenshots/` from the +committed `fixtures/` and runs the same download, embed and cosine-gate +steps, so every extras run log is a live proof that the gate works. + +## Fixtures + +`fixtures/` holds tiny synthetic UI mockups generated by +`make_fixtures.py` (Pillow only, no fonts): + +- `baseline/home.png` + `screenshots/home.png` and + `baseline/detail.png` + `screenshots/detail.png`: nearly identical pairs + (a 2 px element nudge stands in for font/AA jitter), so both pass the + 0.98 gate. +- `screenshots/settings.png`: clearly different, with no baseline pair, so + the comparison prints it as `NEW` instead of failing. A healthy proof run + stays green. + +Regenerate with `python3 examples/ci-visual-regression/make_fixtures.py`. ## What it does diff --git a/examples/ci-visual-regression/fixtures/baseline/detail.png b/examples/ci-visual-regression/fixtures/baseline/detail.png new file mode 100644 index 0000000..6ff338c Binary files /dev/null and b/examples/ci-visual-regression/fixtures/baseline/detail.png differ diff --git a/examples/ci-visual-regression/fixtures/baseline/home.png b/examples/ci-visual-regression/fixtures/baseline/home.png new file mode 100644 index 0000000..0c37c76 Binary files /dev/null and b/examples/ci-visual-regression/fixtures/baseline/home.png differ diff --git a/examples/ci-visual-regression/fixtures/screenshots/detail.png b/examples/ci-visual-regression/fixtures/screenshots/detail.png new file mode 100644 index 0000000..602fe3a Binary files /dev/null and b/examples/ci-visual-regression/fixtures/screenshots/detail.png differ diff --git a/examples/ci-visual-regression/fixtures/screenshots/home.png b/examples/ci-visual-regression/fixtures/screenshots/home.png new file mode 100644 index 0000000..76feadd Binary files /dev/null and b/examples/ci-visual-regression/fixtures/screenshots/home.png differ diff --git a/examples/ci-visual-regression/fixtures/screenshots/settings.png b/examples/ci-visual-regression/fixtures/screenshots/settings.png new file mode 100644 index 0000000..6c0eb63 Binary files /dev/null and b/examples/ci-visual-regression/fixtures/screenshots/settings.png differ diff --git a/examples/ci-visual-regression/make_fixtures.py b/examples/ci-visual-regression/make_fixtures.py new file mode 100644 index 0000000..4fa629f --- /dev/null +++ b/examples/ci-visual-regression/make_fixtures.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Regenerate the fixture images for the ci-visual-regression example. + +Writes tiny synthetic "screenshots" (flat UI mockups, no fonts needed) into +fixtures/baseline/ and fixtures/screenshots/: + + home.png nearly identical pair (a 2 px nudge on one element stands in + detail.png for font/AA jitter between runs) + settings.png clearly different, screenshots-only: the gate reports it as + NEW instead of failing, so the proof run stays green + +Run: python3 examples/ci-visual-regression/make_fixtures.py (needs Pillow) +""" + +from pathlib import Path + +from PIL import Image, ImageDraw + +HERE = Path(__file__).resolve().parent +W, H = 400, 300 + +BG = (245, 246, 248) +CARD = (255, 255, 255) +BORDER = (210, 214, 220) +TEXT_DARK = (90, 98, 110) +TEXT_MID = (160, 168, 180) +ACCENT = (66, 133, 244) + + +def nav(d, accent=ACCENT): + d.rectangle([0, 0, W, 40], fill=accent) + for i in range(3): + x = 16 + i * 80 + d.rectangle([x, 14, x + 56, 26], fill=CARD) + + +def card(d, box): + d.rectangle(box, fill=CARD, outline=BORDER) + + +def home(jitter=0): + img = Image.new("RGB", (W, H), BG) + d = ImageDraw.Draw(img) + nav(d) + card(d, [24, 64, 376, 140]) + d.rectangle([40, 84 + jitter, 240, 96 + jitter], fill=TEXT_DARK) + d.rectangle([40, 108, 320, 118], fill=TEXT_MID) + card(d, [24, 160, 190, 276]) + card(d, [210, 160, 376, 276]) + d.rectangle([40, 184, 100, 196], fill=ACCENT) + d.rectangle([226, 184, 286, 196], fill=(234, 67, 53)) + d.rectangle([40, 208 + jitter, 160, 218 + jitter], fill=TEXT_MID) + d.rectangle([226, 208, 350, 218], fill=TEXT_MID) + d.rectangle([40, 232, 150, 242], fill=TEXT_MID) + d.rectangle([226, 232, 340, 242], fill=TEXT_MID) + return img + + +def detail(jitter=0): + img = Image.new("RGB", (W, H), BG) + d = ImageDraw.Draw(img) + nav(d, accent=(52, 168, 83)) + card(d, [24, 64, 240, 276]) + d.rectangle([40, 88, 224, 200], fill=(52, 168, 83)) + d.rectangle([40, 216 + jitter, 180, 228 + jitter], fill=TEXT_DARK) + d.rectangle([40, 240, 200, 250], fill=TEXT_MID) + card(d, [256, 64, 376, 276]) + for i in range(4): + y = 84 + i * 44 + d.ellipse([272, y, 296, y + 24], fill=ACCENT) + d.rectangle([308, y + 4, 364, y + 12], fill=TEXT_DARK) + d.rectangle([308, y + 16, 352, y + 22], fill=TEXT_MID) + return img + + +def settings_new(): + """A clearly different page: dark theme, sidebar layout, no baseline pair.""" + img = Image.new("RGB", (W, H), (24, 26, 32)) + d = ImageDraw.Draw(img) + d.rectangle([0, 0, 96, H], fill=(32, 35, 44)) + for i in range(5): + d.rectangle([16, 24 + i * 52, 80, 44 + i * 52], fill=(70, 76, 94)) + d.rectangle([112, 24, 376, 56], fill=(200, 205, 216)) + for i in range(3): + y = 80 + i * 68 + d.rectangle([112, y, 376, y + 52], fill=(38, 42, 52)) + d.ellipse([128, y + 14, 152, y + 38], fill=(251, 188, 5)) + d.rectangle([168, y + 12, 300, y + 22], fill=(150, 156, 170)) + d.rectangle([168, y + 30, 260, y + 38], fill=(96, 102, 116)) + return img + + +def main(): + pairs = { + "baseline": {"home.png": home(), "detail.png": detail()}, + "screenshots": { + "home.png": home(jitter=2), + "detail.png": detail(jitter=-2), + "settings.png": settings_new(), + }, + } + for sub, images in pairs.items(): + out = HERE / "fixtures" / sub + out.mkdir(parents=True, exist_ok=True) + for name, img in images.items(): + img.save(out / name) + print(f"wrote {out.relative_to(HERE) / name}") + + +if __name__ == "__main__": + main() diff --git a/wasm/index.html b/wasm/index.html index a86c4f7..f7de5b9 100644 --- a/wasm/index.html +++ b/wasm/index.html @@ -30,14 +30,16 @@