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
100 changes: 100 additions & 0 deletions .github/workflows/extras.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 71 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 19 additions & 4 deletions docs/wasm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
25 changes: 22 additions & 3 deletions examples/ci-visual-regression/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
111 changes: 111 additions & 0 deletions examples/ci-visual-regression/make_fixtures.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 7 additions & 5 deletions wasm/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,16 @@ <h1>dinov2.cpp wasm demo</h1>
<fieldset>
<legend>1. Model</legend>
<label>GGUF URL
<input id="modelUrl" type="text" value="model.gguf"
<input id="modelUrl" type="text"
value="https://huggingface.co/dinov2-cpp-core/dinov2-with-registers-small-gguf/resolve/main/model.gguf"
placeholder="model.gguf or https://huggingface.co/dinov2-cpp-core/dinov2-small-gguf/resolve/main/model.gguf">
</label>
<p class="hint">
Default <code>model.gguf</code> is fetched relative to this page: drop a GGUF next to
<code>index.html</code>. huggingface.co <code>/resolve/</code> URLs send CORS headers, so
e.g. <code>https://huggingface.co/dinov2-cpp-core/dinov2-small-gguf/resolve/main/model.gguf</code>
(~50 MB f16 ViT-S) also works directly.
Default is the ViT-S with registers GGUF fetched straight from Hugging Face:
<code>/resolve/</code> URLs send CORS headers, so the hosted demo works with
no model committed. Register tokens give cleaner patch heatmaps. A relative
<code>model.gguf</code> also works if you drop a GGUF next to
<code>index.html</code>.
</p>
<button id="loadBtn">Load model</button>
<progress id="modelProgress" value="0" max="1" hidden></progress>
Expand Down
Loading