File size: 14,513 Bytes
648abbe
 
a37b11f
 
 
 
 
 
 
 
 
 
648abbe
 
 
a37b11f
 
648abbe
 
 
20b067b
648abbe
05957da
c1a8f23
 
 
 
648abbe
 
20b067b
 
 
c1a8f23
648abbe
 
 
 
d6ca533
648abbe
c1a8f23
20b067b
 
a37b11f
c1a8f23
a37b11f
 
20b067b
 
 
 
a37b11f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648abbe
 
 
 
 
 
 
 
 
 
 
 
 
 
d6ca533
 
648abbe
 
 
 
 
 
 
 
 
d6ca533
 
 
 
 
 
 
 
 
 
 
 
 
 
648abbe
 
20b067b
648abbe
05957da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a37b11f
 
 
 
 
 
 
 
 
 
 
 
 
 
648abbe
 
 
20b067b
 
 
648abbe
20b067b
 
 
a37b11f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648abbe
20b067b
648abbe
a37b11f
648abbe
 
 
 
20b067b
648abbe
 
 
 
 
 
 
 
 
 
20b067b
648abbe
20b067b
648abbe
 
 
 
 
 
 
 
20b067b
 
c1a8f23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cffbfb2
c1a8f23
 
 
 
 
 
 
cffbfb2
c1a8f23
 
648abbe
 
 
c1a8f23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648abbe
 
a37b11f
648abbe
 
a37b11f
 
 
 
 
 
 
 
648abbe
 
 
 
a37b11f
 
 
 
 
648abbe
a37b11f
648abbe
 
a37b11f
 
 
648abbe
05957da
 
 
 
 
 
 
648abbe
a37b11f
 
 
648abbe
 
a37b11f
 
648abbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
"""Iconclass-9B ZeroGPU demo — gradio.Server pattern with a custom Tufte frontend.

Upload an artwork -> the 9B VLM (davanstrien/qwen35-9b-iconclass-sft-multitask-2ep)
predicts Iconclass codes -> each code is shown with its decoded meaning (via the
`iconclass` package) and a link to https://iconclass.org/{code}.

The multitask model supports three prompt modes (templates must stay byte-identical
to training — see model-training/iconclass-qwen35/train_sft_brill.py):
  1. standard      — "classify this image"
  2. N-conditioned — "...containing exactly N codes"   (cataloguing-depth knob)
  3. completion    — "image already has codes [...]; add (exactly N | any) more"
                     (catalogue-densification: existing codes condition the model)

Inference recipe (empirically de-risked, do not change):
  enable_thinking=False, max_new_tokens=768, do_sample=False, repetition_penalty=1.1
The repetition_penalty is ESSENTIAL: without it greedy decoding degenerates into a
runaway sibling-code enumeration that blows the token budget and yields invalid JSON.
"""
from __future__ import annotations

import json
import os
import re
import threading
import time
import urllib.request
import uuid
from functools import lru_cache
from pathlib import Path

import spaces
import torch
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gradio import Server
from gradio.data_classes import FileData
from iconclass import init as ic_init
from iconclass import split_on_colon
from PIL import Image
from pydantic import BaseModel
from transformers import AutoModelForImageTextToText, AutoProcessor

MODEL = "davanstrien/qwen35-9b-iconclass-sft-multitask-2ep"
SEED_REPO = "davanstrien/iconclass-annotation-seed"

# --- Prompt templates: KEEP IN SYNC with train_sft_brill.py (training format) ---
INSTRUCTION = (
    "Classify this image using Iconclass codes. "
    "Return a JSON object with key 'iconclass-codes' containing a list of codes."
)
INSTRUCTION_N = (
    "Classify this image using Iconclass codes. "
    "Return a JSON object with key 'iconclass-codes' containing exactly {n} codes."
)
INSTRUCTION_COMPLETE_N = (
    "This image already has the following Iconclass codes: {existing}. "
    "Add exactly {n} more Iconclass codes for aspects not yet covered. "
    "Return a JSON object with key 'iconclass-codes' containing only the new codes."
)
INSTRUCTION_COMPLETE_OPEN = (
    "This image already has the following Iconclass codes: {existing}. "
    "Add any further applicable Iconclass codes for aspects not yet covered. "
    "Return a JSON object with key 'iconclass-codes' containing only the new codes."
)

HERE = Path(__file__).resolve().parent

# ---------------------------------------------------------------------------
# Iconclass decoding
# ---------------------------------------------------------------------------
_ic = ic_init()


def _base(code: str) -> str:
    """Strip modifiers/keys to the base notation used for lookup (e.g. 61B(+52) -> 61B)."""
    return code.split("(")[0].strip() if isinstance(code, str) else ""


@lru_cache(maxsize=100_000)
def _decode_one(code: str) -> str:
    """Decode a single (non-composite) Iconclass notation to English."""
    base = _base(code)
    try:
        node = _ic[base]
        txt = node() if callable(node) else str(node)
        return txt or base or code
    except Exception:
        return base or code


@lru_cache(maxsize=100_000)
def decode_meaning(code: str) -> str:
    """Decoded English meaning for an Iconclass code.

    Composite codes joined with a colon (e.g. 31D15:61BB) are decoded part-by-part
    and joined with a middle dot. Falls back to the raw code on any failure.
    """
    try:
        parts = split_on_colon(code)
    except Exception:
        parts = [code]
    return " · ".join(_decode_one(p) for p in parts) if parts else _decode_one(code)


def code_url(code: str) -> str:
    return f"https://iconclass.org/{code}"


@lru_cache(maxsize=100_000)
def _decode_exact(code: str) -> str:
    """Decode a notation EXACTLY as given (keyed/named forms resolve in the lib);
    falls back to the base-stripped decode, then to the raw code."""
    try:
        node = _ic[code]
        txt = node() if callable(node) else str(node)
        if txt:
            # the lib doubles the name on '(NAME)' forms: "... (AZOR) (AZOR)"
            return re.sub(r"(\([^()]+\))\s*\1$", r"\1", txt)
    except Exception:
        pass
    return _decode_one(code)


@lru_cache(maxsize=100_000)
def hierarchy_path(code: str) -> tuple:
    """Decoded root->leaf ladder(s) for a code, for non-expert display.

    Returns a tuple of ladders (one per ':'-composite part); each ladder is a
    tuple of {"code", "text"} rungs. Uses the iconclass lib's Notation.path()
    (handles letter steps, '(+key)' rungs and '(NAME)' forms natively); drops
    the '(...)' placeholder rung when a named leaf follows it.
    """
    try:
        parts = split_on_colon(code) or [code]
    except Exception:
        parts = [code]
    ladders = []
    for part in parts:
        try:
            rungs = [str(r) for r in _ic[part].path()]
        except Exception:
            rungs = []
        if not rungs:
            rungs = [part]
        if rungs[-1] != part:
            rungs.append(part)  # e.g. named forms whose path ends at '(...)'
        rungs = [
            r for i, r in enumerate(rungs)
            if not (r.endswith("(...)") and i + 1 < len(rungs))
        ]
        ladder, prev_txt = [], None
        for r in rungs:
            txt = _decode_exact(r)
            if txt == prev_txt:
                continue  # skip rungs that add no new meaning
            prev_txt = txt
            ladder.append({"code": r, "text": txt})
        ladders.append(tuple(ladder))
    return tuple(ladders)


def _parse_existing(existing_codes: str) -> list[str]:
    """Parse the user's 'existing codes' input (comma/semicolon/newline separated)."""
    if not existing_codes:
        return []
    raw = existing_codes.replace(";", ",").replace("\n", ",").split(",")
    seen, out = set(), []
    for c in raw:
        c = c.strip()
        if c and c not in seen:
            seen.add(c)
            out.append(c)
    return out


# ---------------------------------------------------------------------------
# Model (loaded once at startup on CPU; moved to cuda inside the GPU function)
# ---------------------------------------------------------------------------
print("loading processor + model (CPU)…", flush=True)
processor = AutoProcessor.from_pretrained(MODEL)
model = AutoModelForImageTextToText.from_pretrained(MODEL, torch_dtype=torch.bfloat16)
model.eval()
print("loaded.", flush=True)


def _build_instruction(num_codes: int, existing: list[str]) -> str:
    """Pick the training-format instruction for the requested mode.

    num_codes <= 0 means 'auto' (model decides how many codes).
    """
    if existing:
        if num_codes and num_codes > 0:
            return INSTRUCTION_COMPLETE_N.format(existing=json.dumps(existing), n=num_codes)
        return INSTRUCTION_COMPLETE_OPEN.format(existing=json.dumps(existing))
    if num_codes and num_codes > 0:
        return INSTRUCTION_N.format(n=num_codes)
    return INSTRUCTION


def _run_model(image: Image.Image, instruction: str) -> str:
    """Run the validated inference recipe and return the raw decoded text."""
    inner = getattr(processor, "tokenizer", processor)
    msgs = [
        {"role": "user", "content": [{"type": "image"}, {"type": "text", "text": instruction}]}
    ]
    text = inner.apply_chat_template(
        msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False
    )
    inputs = processor(text=text, images=[image.convert("RGB")], return_tensors="pt").to("cuda")
    out = model.generate(
        **inputs,
        max_new_tokens=768,
        do_sample=False,
        repetition_penalty=1.1,
    )
    return processor.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)


def _parse_codes(raw: str) -> list[str]:
    try:
        obj = json.loads(raw)
    except Exception:
        return []
    codes = obj.get("iconclass-codes", []) if isinstance(obj, dict) else []
    seen, out = set(), []
    for c in codes:
        if isinstance(c, str) and c and c not in seen:
            seen.add(c)
            out.append(c)
    return out


# ---------------------------------------------------------------------------
# Annotation mode: seed (precomputed predictions, no GPU) + verdict storage
# ---------------------------------------------------------------------------
_seed_cache: dict = {}
_seed_lock = threading.Lock()


def get_seed() -> dict:
    """Server-side proxy for seed.json (same-origin for the frontend, cached)."""
    with _seed_lock:
        if not _seed_cache:
            url = f"https://huggingface.co/datasets/{SEED_REPO}/resolve/main/seed.json"
            try:
                with urllib.request.urlopen(url, timeout=30) as r:
                    _seed_cache.update(json.loads(r.read()))
            except Exception as exc:
                return {"error": f"seed unavailable: {exc}", "items": []}
    return _seed_cache


# Verdicts: JSONL appended locally, synced to a PRIVATE HF Bucket every couple
# of minutes (object-store semantics: each flush re-uploads changed files; one
# uniquely-named file per app instance so concurrent replicas never collide).
ANNOTATIONS_BUCKET = "davanstrien/iconclass-annotations"
_ann_dir = Path("annotations")
_ann_dir.mkdir(exist_ok=True)
_ann_file = _ann_dir / f"events_{uuid.uuid4().hex[:12]}.jsonl"
_ann_lock = threading.Lock()
_bucket_ok = False


def _start_bucket_flusher():
    global _bucket_ok
    if not os.environ.get("HF_TOKEN"):
        print("HF_TOKEN not set; annotations stored locally only (lost on restart)", flush=True)
        return
    try:
        from huggingface_hub import HfApi

        api = HfApi()
        api.create_bucket(ANNOTATIONS_BUCKET, private=True, exist_ok=True)
        _bucket_ok = True
        print(f"annotation bucket flusher -> hf://buckets/{ANNOTATIONS_BUCKET}/raw", flush=True)
    except Exception as exc:
        print(f"bucket unavailable ({exc}); annotations stored locally only", flush=True)
        return

    def _flush_loop():
        last_size = -1
        while True:
            time.sleep(120)
            try:
                size = _ann_file.stat().st_size if _ann_file.exists() else 0
                if size and size != last_size:
                    with _ann_lock:
                        api.sync_bucket(
                            source=str(_ann_dir),
                            dest=f"hf://buckets/{ANNOTATIONS_BUCKET}/raw",
                            quiet=True,
                        )
                    last_size = size
            except Exception as exc:  # transient errors: retry next tick
                print(f"bucket flush failed (will retry): {exc}", flush=True)

    threading.Thread(target=_flush_loop, daemon=True, name="bucket-flusher").start()


_start_bucket_flusher()


class Annotation(BaseModel):
    seed_id: int
    code: str
    verdict: str  # simple ui: "yes" | "no" | "skip"; ladder ui: "depth" | "none" | "skip"
    depth_index: int | None = None  # 0-based rung index when verdict == "depth"
    rung_code: str | None = None
    ladder_index: int | None = None  # which ':'-part, for composite codes
    max_depth: int | None = None
    gt: bool | None = None
    judge: str | None = None
    session: str = ""
    ui: str | None = None  # "simple-v1" | "ladder-v1" — distinguishes the two voting UIs


app = Server()


@app.get("/seed")
async def seed_endpoint():
    return JSONResponse(get_seed())


@app.post("/annotate")
async def annotate_endpoint(ann: Annotation):
    event = ann.model_dump()
    event["model_version"] = MODEL
    event["ts"] = time.time()
    with _ann_lock:
        with _ann_file.open("a") as f:
            f.write(json.dumps(event) + "\n")
    return JSONResponse({"ok": True, "persisted": _bucket_ok})


@app.api(name="classify")
@spaces.GPU(duration=120)
def classify(image_path: FileData, num_codes: int = 0, existing_codes: str = "") -> dict:
    """Predict Iconclass codes for an uploaded artwork.

    Modes (selected by the optional params):
      - default: model decides which / how many codes
      - num_codes > 0: ask for exactly that many codes (cataloguing-depth knob)
      - existing_codes non-empty: completion mode — the given codes condition the
        model, which proposes codes for aspects *not yet covered* (additions only)

    Returns {"codes": [...], "existing": [...], "mode": str, "raw": str, "error": str|None}
    where each code entry is {"code", "meaning", "url"}.
    """
    path = image_path["path"] if isinstance(image_path, dict) else image_path
    image = Image.open(path).convert("RGB")

    existing = _parse_existing(existing_codes)
    n = int(num_codes) if num_codes else 0
    instruction = _build_instruction(n, existing)
    mode = "completion" if existing else ("exact-n" if n > 0 else "standard")

    model.to("cuda")
    raw = _run_model(image, instruction)

    codes = _parse_codes(raw)
    if existing:  # never echo a given code back as an "addition"
        given = set(existing)
        codes = [c for c in codes if c not in given]
    decoded = [
        {
            "code": c,
            "meaning": decode_meaning(c),
            "url": code_url(c),
            "path": [list(ladder) for ladder in hierarchy_path(c)],
        }
        for c in codes
    ]
    existing_decoded = [
        {"code": c, "meaning": decode_meaning(c), "url": code_url(c)} for c in existing
    ]
    return {
        "codes": decoded,
        "existing": existing_decoded,
        "mode": mode,
        "raw": raw,
        "error": None if decoded else "No valid Iconclass codes parsed from the model output.",
    }


# ---------------------------------------------------------------------------
# Frontend
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def homepage():
    return (HERE / "index.html").read_text(encoding="utf-8")


# Serve example artworks (and any static assets) under /static
_static_dir = HERE / "examples"
if _static_dir.is_dir():
    app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")


if __name__ == "__main__":
    app.launch(show_error=True)