szwendaczjakomaj Claude Sonnet 4.6 commited on
Commit
816f700
·
0 Parent(s):

chore: HF Space deployment — full pipeline + inventory

Browse files

Single squashed commit for clean HF push. Contains:
- app.py: vision pipeline with json_schema output, Humbrol→Tamiya lookup
- server.py: gr.Server streaming endpoint + frontend middleware (/assets, /examples)
- inventory.py: gspread integration (GOOGLE_SERVICE_ACCOUNT_JSON + GOOGLE_SHEET_ID)
- frontend/: Airfix Dogfighter UI
- requirements.txt: requests, pillow, gspread

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (12) hide show
  1. CLAUDE.md +51 -0
  2. app.py +346 -0
  3. data/humbrol_tamyia.csv +172 -0
  4. frontend/app.js +111 -0
  5. frontend/index.html +397 -0
  6. inventory.py +27 -0
  7. requirements.txt +3 -0
  8. server.py +73 -0
  9. tests/test_app.py +249 -0
  10. tests/test_inventory.py +46 -0
  11. tests/test_server.py +32 -0
  12. ui.py +265 -0
CLAUDE.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Setup & running
6
+
7
+ ```bash
8
+ source venv/bin/activate
9
+ python ui.py # Gradio UI at http://localhost:7860
10
+ ```
11
+
12
+ The Radxa inference box (`192.168.68.119:8088`) must be reachable on the LAN. Start llama-server on it with:
13
+ ```bash
14
+ taskset -c 4-7 llama-server --threads 4 --jinja --ctx-size 4096 --host 0.0.0.0 --port 8088 \
15
+ -m <model.gguf> --mmproj <mmproj.gguf>
16
+ ```
17
+
18
+ ## Architecture
19
+
20
+ Two files: `app.py` (logic) + `ui.py` (CSS, layout). Pipeline:
21
+
22
+ 1. `encode_image` — PIL resize to 960px longest side → base64 JPEG (A/B tested 2026-06-08: 640px loses numeric codes on small-print instruction sheet photos; 960px = 100% recall, +~60s vs 640; 1280px identical recall at +90s — not worth it. Shop screenshots are already ≤640px so unaffected.)
23
+ 2. `call_llama` — POST to `llama-server` OpenAI-compatible endpoint (`/v1/chat/completions`) with image as base64 data URI; CPU inference, ~90–180s depending on image type
24
+ 3. `extract_codes` — json_schema constraint → json.loads → numeric extraction; falls back to tolerant regex for non-json modes
25
+ 4. Gradio `gr.Blocks` UI — single-page, upload → analyze → display raw output + extracted codes
26
+
27
+ Each request must be a fresh conversation (no message history) — otherwise llama-server reuses the previous image encoding.
28
+
29
+ ## Model
30
+
31
+ InternVL3.5-2B Q6_K_L GGUF + mmproj f16 GGUF on Radxa Dragon Q6A (CPU-only, Ubuntu).
32
+
33
+ ## Pending (in priority order)
34
+
35
+ 1. ~~Humbrol → Tamiya conversion table~~ — done (CSV lookup + mojehobby.pl shop links)
36
+ 2. Structured model output (JSON-in-prompt or GBNF grammar instead of free-text regex)
37
+ 3. ~~Brand autodetection~~ — solved by brand dropdown (deterministic, no extra work needed)
38
+ 4. ~~HF Space deployment~~ — done (`build-small-hackathon/paint_match`, branch `hf-deploy`); Cloudflare Tunnel pending (do weekendu)
39
+ 5. Airfix Dogfighter aesthetic UI (deferred until pipeline is solid)
40
+ 6. ~~Paint inventory integration~~ — done (gspread + service account; `GOOGLE_SERVICE_ACCOUNT_JSON` + `GOOGLE_SHEET_ID` env vars; sheet "Hakaton", sheet1; owned badge "✓ ISSUED" replaces shop link)
41
+
42
+ ## Hackathon constraints
43
+
44
+ "Off-the-grid": inference must stay on the Radxa — no hosted LLM APIs. Cloudflare Tunnel and HF Space are acceptable front-door plumbing. Deadline: 2026-06-15.
45
+
46
+ ## Known pitfalls
47
+
48
+ - Port 8080 on the Radxa is taken; llama-server uses 8088.
49
+ - First request after server start is warmup — disregard its timing.
50
+ - macOS `base64` is BSD: no `-w0`. Use `base64 -i file` or `base64 < file`.
51
+ - `hf` not `huggingface-cli` in this venv.
app.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import csv
3
+ import io
4
+ import itertools
5
+ import json
6
+ import os
7
+ import re
8
+ import threading
9
+ import time
10
+ import requests
11
+ from html import escape
12
+ from PIL import Image, ImageOps
13
+ import inventory
14
+
15
+ LLAMA_SERVER = os.environ.get("LLAMA_SERVER", "http://192.168.68.119:8088")
16
+ TIMEOUT = 300 # seconds; CPU inference is slow
17
+
18
+ # ---------- Humbrol → Tamiya lookup ----------
19
+
20
+ def _load_humbrol_tamiya() -> dict[str, tuple[str, str]]:
21
+ path = os.path.join(os.path.dirname(__file__), "data", "humbrol_tamyia.csv")
22
+ result: dict[str, tuple[str, str]] = {}
23
+ with open(path, newline="") as f:
24
+ for row in csv.DictReader(f):
25
+ h = row["humbrol"].strip()
26
+ t = row["tamiya"].strip()
27
+ n = row["tamiya_name"].strip()
28
+ if h and h not in result:
29
+ result[h] = (t, n)
30
+ return result
31
+
32
+ HUMBROL_TO_TAMIYA = _load_humbrol_tamiya()
33
+
34
+ # ---------- Format configurations ----------
35
+
36
+ # One prompt + one regex shared by every document type. The model returns a plain
37
+ # `- CODE: NAME` list regardless of source layout, so a single tolerant regex parses
38
+ # all of them — no per-format coupling to break when the model varies its output.
39
+ LIST_PROMPT = (
40
+ "List every paint code visible in this image."
41
+ # "Output one per line, exactly as `- CODE: NAME`, and nothing else. "
42
+ # "For a paint given as a mix ratio, join the codes with + (for example 118+34)."
43
+ )
44
+
45
+ # Tolerant by design: the 2B model formats the same prompt differently per input —
46
+ # a clean "- 11: Silver" for sparse instruction sheets, but an echoed, noisy
47
+ # "- Acrylic Paint 11 - Silver - Metallic - CODE: 11 - Silver" for dense shop
48
+ # screenshots. So we don't anchor to line start or a fixed separator; we grab the
49
+ # first code-like token, then a ':' or '-' separator, then the name up to the next
50
+ # separator/newline. Duplicates (shop lines repeat the code) are deduped downstream.
51
+ CODE_REGEX = re.compile(
52
+ r'(?P<code>\d+(?:\+\d+)?)\s*[:\-]\s*(?P<name>[^-:\n]+?)\s*(?=[-–\n]|$)'
53
+ )
54
+
55
+ # ---------- Output constraint (per-request, switchable) ----------
56
+ #
57
+ # A 2B model can't reliably *follow* a format instruction (longer prompt = more
58
+ # random), so instead of asking we constrain the sampler. The constraint is a
59
+ # per-request payload field — no model/server change, no restart. Flip OUTPUT_MODE
60
+ # and re-run the same image to A/B which mode gives the best recall + stability.
61
+ #
62
+ # "json" — response_format json_schema; output parsed with json.loads
63
+ # "line" — GBNF grammar forcing exactly the "- CODE: NAME" lines CODE_REGEX expects
64
+ # "free" — no constraint; free text + tolerant regex (legacy baseline)
65
+ OUTPUT_MODE = "json"
66
+
67
+ # No `pattern` on `code`: this build of llama.cpp accepts the pattern at schema
68
+ # conversion but its structured-output response parser then 500s on real input
69
+ # (the json_schema→GBNF→parser path is fragile here). So the schema enforces only
70
+ # shape; the model tends to dump the whole echoed product line into `code`, which
71
+ # we clean up downstream (see _clean_code). The `line` GBNF mode is the stronger
72
+ # alternative — it forces digits in the code position without this machinery.
73
+ JSON_SCHEMA = {
74
+ "type": "array",
75
+ "items": {
76
+ "type": "object",
77
+ "properties": {
78
+ "code": {"type": "string"},
79
+ "name": {"type": "string"},
80
+ },
81
+ "required": ["code"],
82
+ },
83
+ }
84
+
85
+ # Forces exactly the line shape CODE_REGEX already parses, so the "line" path
86
+ # reuses the existing regex downstream with zero new parsing code. The name is
87
+ # length-capped ({1,40}): unbounded [^\n]+ let the model loop a phrase forever
88
+ # (e.g. "(14ml) each. (14ml) each. …") until max_tokens — the cap forces the
89
+ # newline and lets generation terminate.
90
+ LINE_GRAMMAR = r'''root ::= item+
91
+ item ::= "- " code ": " name "\n"
92
+ code ::= [0-9]+ ("+" [0-9]+)?
93
+ name ::= [^\n]{1,40}'''
94
+
95
+ FORMATS = {
96
+ "airfix_shop": {
97
+ "label": "Airfix shop screenshot",
98
+ "prompt": LIST_PROMPT,
99
+ "regex": CODE_REGEX,
100
+ },
101
+ "airfix_instruction": {
102
+ "label": "Airfix paper instruction sheet",
103
+ "prompt": LIST_PROMPT,
104
+ "regex": CODE_REGEX,
105
+ },
106
+ }
107
+
108
+ # ---------- Image & inference ----------
109
+
110
+ def encode_image(img: Image.Image) -> str:
111
+ """Correct EXIF orientation, resize to max 960px (longest side), encode as base64 JPEG."""
112
+ img = ImageOps.exif_transpose(img)
113
+ img = img.convert("RGB")
114
+ # 960px: A/B test showed 640px is insufficient for small-print instruction sheets
115
+ # (model sees colour names but not numeric codes). 960px recovers 100% recall on
116
+ # paper photos (3000+px originals) with ~60s extra vs 640px. 1280px gave identical
117
+ # recall at +90s — no gain. Shop screenshots are already ≤640px so unchanged.
118
+ img.thumbnail((960, 960))
119
+ buf = io.BytesIO()
120
+ img.save(buf, format="JPEG", quality=90)
121
+ return base64.b64encode(buf.getvalue()).decode()
122
+
123
+
124
+ def call_llama(img_b64: str, prompt: str) -> str:
125
+ payload = {
126
+ "messages": [{
127
+ "role": "user",
128
+ "content": [
129
+ {"type": "text", "text": prompt},
130
+ {"type": "image_url",
131
+ "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
132
+ ]
133
+ }],
134
+ "temperature": 0.0,
135
+ # Dense shop screenshots (e.g. dual enamel+acrylic listings) overran 500
136
+ # tokens and truncated mid-JSON, which json.loads can't parse at all. 1024
137
+ # fits them; short lists still stop early on EOS, so only long ones pay.
138
+ "max_tokens": 1024,
139
+ # Critical for multimodal: llama-server reuses KV/prompt cache across
140
+ # consecutive requests (LRU slots), bleeding a previous image's encoding
141
+ # into the next and producing runaway garbage on back-to-back calls. Force
142
+ # a fresh evaluation every request so results are order-independent.
143
+ "cache_prompt": False,
144
+ }
145
+ if OUTPUT_MODE == "json":
146
+ payload["response_format"] = {
147
+ "type": "json_schema",
148
+ "json_schema": {"name": "paint_codes", "schema": JSON_SCHEMA},
149
+ }
150
+ elif OUTPUT_MODE == "line":
151
+ payload["grammar"] = LINE_GRAMMAR
152
+ r = requests.post(
153
+ f"{LLAMA_SERVER}/v1/chat/completions",
154
+ json=payload,
155
+ timeout=TIMEOUT,
156
+ )
157
+ r.raise_for_status()
158
+ return r.json()["choices"][0]["message"]["content"]
159
+
160
+ # ---------- Parsing ----------
161
+
162
+ def strip_parens(text: str) -> str:
163
+ return re.sub(r'\([^)]*\)', '', text)
164
+
165
+
166
+ def parse_entries(text: str, pattern: re.Pattern) -> list[dict]:
167
+ return [m.groupdict() for m in pattern.finditer(text)]
168
+
169
+
170
+ def _loads_tolerant(raw: str):
171
+ """json.loads, but salvage a truncated array (model hit max_tokens mid-entry).
172
+ A cut-off "[ {...}, {...}, {...incomplete" still has complete objects before
173
+ the break: keep through the last '}' and close the array. Returns None if even
174
+ that fails."""
175
+ try:
176
+ return json.loads(raw)
177
+ except (json.JSONDecodeError, TypeError):
178
+ pass
179
+ start, end = raw.find("["), raw.rfind("}")
180
+ if start != -1 and end > start:
181
+ try:
182
+ return json.loads(raw[start:end + 1] + "]")
183
+ except json.JSONDecodeError:
184
+ return None
185
+ return None
186
+
187
+
188
+ def parse_json_entries(raw: str) -> list[dict]:
189
+ """Parse the json_schema-constrained array into {code, name} entries.
190
+
191
+ json_schema enforces the array *shape* but not field semantics, so the 2B
192
+ model often dumps the whole echoed product line into `code`
193
+ ("Acrylic Paint 11 - Silver - Metallic"). We pull the first numeric code
194
+ (optionally a mix like "118+34") out of that field — reliable because it
195
+ operates on the already-isolated entry, not the raw response. Items with no
196
+ code-like token are dropped; a broken payload yields []."""
197
+ data = _loads_tolerant(raw)
198
+ if not isinstance(data, list):
199
+ return []
200
+ entries = []
201
+ for item in data:
202
+ if not isinstance(item, dict):
203
+ continue
204
+ m = re.search(r'\d+(?:\+\d+)?', str(item.get("code", "")))
205
+ if m:
206
+ entries.append({"code": m.group(0), "name": item.get("name")})
207
+ return entries
208
+
209
+
210
+ # Instruction sheets mix paint codes with decal numbers, part numbers and dates,
211
+ # and the 2B model can't reliably tell them apart — it sometimes emits decals as
212
+ # if they were paints (non-deterministic across layouts, though stable per image
213
+ # at temp 0). This is a perception problem the format constraint can't fix, so we
214
+ # drop entries whose *name* gives them away: a decal/stencil/transfer reference,
215
+ # a bare number, or a 4-digit year. A missing name is NOT evidence (the code still
216
+ # matters for the Tamiya lookup), so those are kept.
217
+ NON_PAINT_NAME = re.compile(r'decal|stencil|transfer|\d{4}', re.IGNORECASE)
218
+
219
+
220
+ def is_paint_name(name) -> bool:
221
+ if name is None:
222
+ return True
223
+ s = str(name).strip()
224
+ if not s:
225
+ return True
226
+ if s.replace("+", "").isdigit(): # name is just a number → likely a misread reference
227
+ return False
228
+ return not NON_PAINT_NAME.search(s)
229
+
230
+
231
+ def extract_entries(raw: str, pattern: re.Pattern) -> list[dict]:
232
+ """Branch parsing on the active output mode (JSON for the json_schema
233
+ constraint, tolerant regex otherwise), then drop non-paint entries."""
234
+ if OUTPUT_MODE == "json":
235
+ entries = parse_json_entries(raw)
236
+ else:
237
+ entries = parse_entries(strip_parens(raw), pattern)
238
+ return [e for e in entries if is_paint_name(e.get("name"))]
239
+
240
+
241
+ def expand_codes(entries: list[dict]) -> list[dict]:
242
+ """Split composite codes (e.g. '118+34') into separate items; dedupe."""
243
+ seen = set()
244
+ result = []
245
+ for entry in entries:
246
+ code = entry["code"]
247
+ if "+" in code:
248
+ for c in code.split("+"):
249
+ if c not in seen:
250
+ seen.add(c)
251
+ result.append({"code": c, "name": None})
252
+ else:
253
+ if code not in seen:
254
+ seen.add(code)
255
+ result.append(entry)
256
+ return result
257
+
258
+ # ---------- Main flow ----------
259
+
260
+ def tamiya_shop_url(code: str, name: str) -> str:
261
+ slug_code = re.sub(r'([A-Za-z]+)(\d+)', r'\1-\2', code)
262
+ slug_name = name.replace(".", "").replace(" ", "-")
263
+ return f"https://www.mojehobby.pl/products/{slug_code}-{slug_name}.html"
264
+
265
+
266
+ LOADING_INTERVAL = 8.0 # seconds between status lines; ~90s inference ≈ one pass of 10 lines
267
+
268
+ LOADING_LINES = [
269
+ "▸ Establishing uplink to field unit…",
270
+ "▸ Calibrating optical sensors…",
271
+ "▸ Enhancing image resolution…",
272
+ "▸ Scanning paint tin labels…",
273
+ "▸ Identifying colour swatches…",
274
+ "▸ Cross-referencing Humbrol registry…",
275
+ "▸ Computing Tamiya equivalents…",
276
+ "▸ Consulting quartermaster database…",
277
+ "▸ Verifying stock availability…",
278
+ "▸ Assembling resupply manifest…",
279
+ ]
280
+
281
+
282
+ def _run_analysis(image: Image.Image, format_id: str) -> tuple[str, str]:
283
+ cfg = FORMATS[format_id]
284
+ try:
285
+ img_b64 = encode_image(image)
286
+ raw = call_llama(img_b64, cfg["prompt"])
287
+ entries = extract_entries(raw, cfg["regex"])
288
+ expanded = expand_codes(entries)
289
+ if expanded:
290
+ items = []
291
+ for e in expanded:
292
+ code = e["code"]
293
+ tamiya_code, tamiya_name = HUMBROL_TO_TAMIYA.get(code, (None, ""))
294
+ if tamiya_code is None:
295
+ items.append(f"<li>Humbrol {escape(code)} – ?</li>")
296
+ elif tamiya_code == "":
297
+ items.append(f"<li>Humbrol {escape(code)} – (no Tamiya equivalent)</li>")
298
+ elif tamiya_name:
299
+ if tamiya_code.upper() in inventory.OWNED:
300
+ items.append(
301
+ f'<li class="owned">Humbrol {escape(code)} –'
302
+ f' Tamiya {tamiya_code} {escape(tamiya_name)}'
303
+ f' <span class="owned-badge">&#10003; ISSUED</span>'
304
+ f'<span class="owned-status">On personal charge — no resupply required</span></li>'
305
+ )
306
+ else:
307
+ url = tamiya_shop_url(tamiya_code, tamiya_name)
308
+ items.append(
309
+ f'<li>Humbrol {escape(code)} –'
310
+ f' <a href="{url}" target="_blank">Tamiya {tamiya_code} {tamiya_name} ↗</a></li>'
311
+ )
312
+ else:
313
+ items.append(f"<li>Humbrol {escape(code)} – Tamiya {tamiya_code}</li>")
314
+ codes_str = "<ul>" + "".join(items) + "</ul>"
315
+ else:
316
+ codes_str = "<p>(none parsed)</p>"
317
+ return raw, codes_str
318
+ except requests.Timeout:
319
+ return "Timeout — model took too long.", ""
320
+ except requests.RequestException as e:
321
+ return f"Server error: {e}", ""
322
+
323
+
324
+ def analyze(image: Image.Image, format_id: str):
325
+ """Generator: stream rotating status lines into Field report while inference
326
+ runs on a worker thread, then yield the final (raw report, manifest HTML)."""
327
+ if image is None:
328
+ yield "No image provided.", ""
329
+ return
330
+ box: dict[str, tuple[str, str]] = {}
331
+
332
+ def worker():
333
+ try:
334
+ box["result"] = _run_analysis(image, format_id)
335
+ except Exception as e: # noqa: BLE001 — never leave box empty
336
+ box["result"] = (f"Error: {e}", "")
337
+
338
+ t = threading.Thread(target=worker, daemon=True)
339
+ t.start()
340
+ for line in itertools.cycle(LOADING_LINES):
341
+ if not t.is_alive():
342
+ break
343
+ yield line, "" # status into Field report; manifest stays empty (collapsed)
344
+ time.sleep(LOADING_INTERVAL)
345
+ t.join()
346
+ yield box.get("result", ("Unknown error.", ""))
data/humbrol_tamyia.csv ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ humbrol,tamiya,tamiya_name
2
+ 1,X21,Flat Base
3
+ 2,X5,Green
4
+ 3,X5,Green
5
+ 5,XF66,Light Grey
6
+ 7,XF57,Buff
7
+ 9,,
8
+ 10,X9,Brown
9
+ 11,X11,Chrome Silver
10
+ 12,XF6,Copper
11
+ 14,X4,Blue
12
+ 15,X3,Royal Blue
13
+ 16,X12,Gold Leaf
14
+ 18,X6,Orange
15
+ 19,X7,Red
16
+ 20,,
17
+ 21,X1,Black
18
+ 22,X2,White
19
+ 23,XF21,Sky
20
+ 24,,
21
+ 25,XF8,Flat Blue
22
+ 26,XF49,Khaki
23
+ 27,XF54,Dark Sea Grey
24
+ 28,XF55,Deck Tan
25
+ 29,XF52,Flat Earth
26
+ 30,XF58,Olive Green
27
+ 31,XF25,Light Sea Grey
28
+ 32,XF24,Dark Grey
29
+ 33,XF1,Flat Black
30
+ 34,XF2,Flat White
31
+ 35,X22,Clear
32
+ 35,,
33
+ 36,,
34
+ 37,,
35
+ 38,X15,Light Green
36
+ 40,,
37
+ 41,,
38
+ 42,,
39
+ 44,,
40
+ 46,,
41
+ 47,,
42
+ 48,X14,Sky Blue
43
+ 49,X21,Flat Base
44
+ 49,,
45
+ 52,X13,Metallic Blue
46
+ 53,X10,Gun Metal
47
+ 54,,
48
+ 55,,
49
+ 56,XF56,Metallic Grey
50
+ 57,,
51
+ 58,,
52
+ 60,XF7,Flat Red
53
+ 61,XF15,Flat Flesh
54
+ 62,,
55
+ 63,XF59,Desert Yellow
56
+ 64,XF12,J.N. Grey
57
+ 65,XF21,Sky
58
+ 66,XF65,Field Grey
59
+ 67,XF63,German Grey
60
+ 68,X16,Purple
61
+ 69,X8,Lemon Yellow
62
+ 70,XF68,NATO Brown
63
+ 71,,
64
+ 72,XF49,Khaki
65
+ 73,XF9,Hull Red
66
+ 74,,
67
+ 75,XF11,J.N. Green
68
+ 76,XF26,Deep Green
69
+ 77,XF50,Field Blue
70
+ 78,XF71,Cockpit Green
71
+ 79,XF50,Field Blue
72
+ 80,X28,Park Green
73
+ 81,XF4,Yellow Green
74
+ 82,,
75
+ 83,XF60,Dark Yellow
76
+ 85,X18,Semi Gloss Black
77
+ 86,XF58,Olive Green
78
+ 87,XF25,Light Sea Grey
79
+ 88,XF26,Deep Green
80
+ 89,,
81
+ 90,XF21,Sky
82
+ 91,XF27,Black Green
83
+ 93,XF59,Desert Yellow
84
+ 94,XF59,Desert Yellow
85
+ 96,XF18,Medium Blue
86
+ 98,XF10,Flat Brown
87
+ 99,XF3,Flat Yellow
88
+ 100,,
89
+ 101,XF5,Flat Green
90
+ 102,,
91
+ 103,,
92
+ 104,XF17,Sea Blue
93
+ 105,XF5,Flat Green
94
+ 106,XF66,Light Grey
95
+ 109,,
96
+ 110,XF55,Deck Tan
97
+ 113,XF68,NATO Brown
98
+ 116,XF13,J.A. Green
99
+ 117,XF58,Olive Green
100
+ 118,XF52,Flat Earth
101
+ 119,XF52,Flat Earth
102
+ 120,XF26,Deep Green
103
+ 121,XF59,Desert Yellow
104
+ 123,XF24,Dark Grey
105
+ 125,XF24,Dark Grey
106
+ 126,XF53,Neutral Grey
107
+ 127,XF19,Sky Grey
108
+ 128,XF53,Neutral Grey
109
+ 129,XF20,Medium Grey
110
+ 130,XF1,Flat Black
111
+ 131,,
112
+ 132,,
113
+ 133,,
114
+ 135,,
115
+ 135,,
116
+ 140,XF54,Dark Sea Grey
117
+ 145,,
118
+ 147,XF14,J.A. Grey
119
+ 148,XF15,Flat Flesh
120
+ 149,XF26,Deep Green
121
+ 150,,
122
+ 153,XF7,Flat Red
123
+ 154,,
124
+ 155,XF62,Olive Drab
125
+ 156,XF53,Neutral Grey
126
+ 157,,
127
+ 159,,
128
+ 160,XF64,Red Brown
129
+ 163,,
130
+ 164,XF24,Dark Grey
131
+ 164,,
132
+ 165,XF54,Dark Sea Grey
133
+ 165,,
134
+ 166,,
135
+ 167,XF19,Sky Grey
136
+ 168,,
137
+ 171,,
138
+ 173,,
139
+ 174,XF7,Flat Red
140
+ 186,XF52,Flat Earth
141
+ 191,X11,Chrome Silver
142
+ 196,XF66,Light Grey
143
+ 200,X17,Pink
144
+ 201,,
145
+ 208,,
146
+ 209,,
147
+ 220,,
148
+ 222,,
149
+ 224,,
150
+ 225,,
151
+ 226,,
152
+ 230,XF18,Medium Blue
153
+ 237,,
154
+ 238,,
155
+ 239,,
156
+ 240,XF22,R.L.M. Grey
157
+ 241,XF27,Black Green
158
+ 242,XF58,Olive Green
159
+ 243,XF26,Deep Green
160
+ 244,XF27,Black Green
161
+ 245,XF63,German Grey
162
+ 246,,
163
+ 247,,
164
+ 248,XF23,Light Blue
165
+ 249,XF60,Dark Yellow
166
+ 251,,
167
+ 252,XF27,Black Green
168
+ 253,,
169
+ 27001,,
170
+ 27002,,
171
+ 27003,,
172
+ 27004,,
frontend/app.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.2.1";
2
+
3
+ const $ = (id) => document.getElementById(id);
4
+
5
+ const SAMPLES = [
6
+ { src: "/examples/paper-2.jpg", fmt: "airfix_instruction", lbl: "Instructions A" },
7
+ { src: "/examples/paper-3.JPG", fmt: "airfix_instruction", lbl: "Instructions B" },
8
+ { src: "/examples/shop-2.png", fmt: "airfix_shop", lbl: "Shop listing" },
9
+ { src: "/examples/shop-3.png", fmt: "airfix_shop", lbl: "Colour chart" },
10
+ ];
11
+
12
+ document.querySelectorAll(".smp").forEach((btn, i) => {
13
+ const s = SAMPLES[i];
14
+ btn.style.backgroundImage = `url('${s.src}')`;
15
+ btn.title = s.lbl;
16
+ btn.addEventListener("click", () => loadSample(s.src, s.fmt));
17
+ });
18
+
19
+ async function loadSample(src, fmt) {
20
+ const resp = await fetch(src);
21
+ const blob = await resp.blob();
22
+ const filename = src.split("/").pop();
23
+ const file = new File([blob], filename, { type: blob.type });
24
+ const dt = new DataTransfer();
25
+ dt.items.add(file);
26
+ $("file").files = dt.files;
27
+ $("file").dispatchEvent(new Event("change"));
28
+ $("format").value = fmt;
29
+ }
30
+
31
+ // On file select: swap the dropzone for the EXHIBIT A evidence frame
32
+ // (dark frame + clip + RECEIVED stamp wrapping the user's uploaded image).
33
+ let previewUrl = null;
34
+ $("file").addEventListener("change", () => {
35
+ const f = $("file").files[0];
36
+ const preview = $("preview");
37
+ const dropzone = $("dropzone");
38
+ const evidence = $("evidence");
39
+ if (previewUrl) { URL.revokeObjectURL(previewUrl); previewUrl = null; }
40
+ if (f) {
41
+ previewUrl = URL.createObjectURL(f);
42
+ preview.src = previewUrl;
43
+ $("src-name").textContent = "SRC: " + f.name;
44
+ dropzone.style.display = "none";
45
+ evidence.classList.add("on");
46
+ } else {
47
+ preview.removeAttribute("src");
48
+ $("src-name").textContent = "SRC: —";
49
+ dropzone.style.display = "";
50
+ evidence.classList.remove("on");
51
+ }
52
+ // Reset the right side to STANDBY (radar) before the next analysis.
53
+ $("reportIdle").style.display = "";
54
+ $("report").style.display = "none";
55
+ });
56
+
57
+ async function run() {
58
+ const file = $("file").files[0];
59
+ const report = $("report");
60
+ const scan = $("scan");
61
+ // Always swap radar idle for the report text element before reporting anything.
62
+ $("reportIdle").style.display = "none";
63
+ report.style.display = "";
64
+ if (!file) {
65
+ report.classList.remove("pulse");
66
+ report.classList.add("alert");
67
+ report.textContent = "No image provided.";
68
+ return;
69
+ }
70
+ const format_id = $("format").value;
71
+
72
+ $("analyze").disabled = true;
73
+ $("manifest").innerHTML = '<div class="idle-m">Awaiting resupply manifest…</div>';
74
+ scan.classList.add("on");
75
+ report.classList.remove("alert");
76
+ report.classList.add("pulse");
77
+ report.textContent = "▸ Establishing uplink to field unit…";
78
+
79
+ try {
80
+ const client = await Client.connect(window.location.origin);
81
+ const job = client.submit("/analyze", { image: handle_file(file), format_id });
82
+
83
+ let finalReceived = false;
84
+ for await (const msg of job) {
85
+ if (msg.type !== "data") continue;
86
+ const [text, manifest] = msg.data; // tuple yielded by the backend
87
+ if (manifest && manifest !== "") { // final event: manifest non-empty
88
+ finalReceived = true;
89
+ report.classList.remove("pulse");
90
+ report.textContent = text;
91
+ $("manifest").innerHTML = manifest;
92
+ } else { // streaming status line
93
+ report.textContent = text;
94
+ }
95
+ }
96
+ if (!finalReceived) {
97
+ report.classList.remove("pulse");
98
+ report.classList.add("alert");
99
+ report.textContent = "Analysis ended without a result — field unit may be offline.";
100
+ }
101
+ } catch (err) {
102
+ report.classList.remove("pulse");
103
+ report.classList.add("alert");
104
+ report.textContent = "Field unit offline — " + (err && err.message ? err.message : err);
105
+ } finally {
106
+ scan.classList.remove("on"); // stop the sweep loop
107
+ $("analyze").disabled = false;
108
+ }
109
+ }
110
+
111
+ $("analyze").addEventListener("click", run);
frontend/index.html ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Paint Match — Colour Identification Unit</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Black+Ops+One&family=Stardos+Stencil:wght@400;700&family=Saira+Condensed:wght@400;500;600;700&family=Saira+Semi+Condensed:wght@500;600;700&family=Special+Elite&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{
12
+ --olive:#46522f;
13
+ --olive-2:#3a4528;
14
+ --olive-dark:#2c351d;
15
+ --panel:rgba(13,17,8,0.74);
16
+ --panel-edge:rgba(199,176,116,0.22);
17
+ --panel-edge-hi:rgba(214,196,140,0.40);
18
+ --inner:#0c0f07;
19
+ --gold:#d3bd83;
20
+ --gold-bright:#e6d29a;
21
+ --gold-dim:#9b8d57;
22
+ --text:#dcd7c0;
23
+ --text-dim:#9a9a80;
24
+ --orange:#d6841f;
25
+ --orange-hi:#f0a338;
26
+ --orange-dk:#a85f12;
27
+ --stamp-red:#a8362c;
28
+ --teletype:#bfe6b6;
29
+ }
30
+ *{box-sizing:border-box}
31
+ html,body{margin:0;padding:0}
32
+ body{
33
+ font-family:"Saira Condensed",sans-serif;
34
+ color:var(--text);
35
+ min-height:100vh;
36
+ background:
37
+ radial-gradient(120% 90% at 50% -10%, rgba(120,134,84,0.18), transparent 60%),
38
+ radial-gradient(140% 120% at 50% 120%, rgba(0,0,0,0.55), transparent 55%),
39
+ linear-gradient(160deg, var(--olive) 0%, var(--olive-2) 45%, var(--olive-dark) 100%);
40
+ position:relative;
41
+ overflow-x:hidden;
42
+ }
43
+ /* weathered metal grain overlay */
44
+ body::before{
45
+ content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
46
+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/></svg>");
47
+ opacity:0.06;mix-blend-mode:overlay;
48
+ }
49
+ /* scratches + grime */
50
+ body::after{
51
+ content:"";position:fixed;inset:0;pointer-events:none;z-index:0;opacity:0.5;
52
+ background-image:
53
+ linear-gradient(105deg, transparent 0 38%, rgba(0,0,0,0.10) 38.4%, transparent 39%),
54
+ linear-gradient(102deg, transparent 0 71%, rgba(0,0,0,0.08) 71.3%, transparent 72%),
55
+ linear-gradient(98deg, transparent 0 12%, rgba(180,190,140,0.05) 12.4%, transparent 13%),
56
+ radial-gradient(2px 2px at 14% 22%, rgba(60,40,20,0.5), transparent 60%),
57
+ radial-gradient(2px 2px at 86% 18%, rgba(60,40,20,0.4), transparent 60%),
58
+ radial-gradient(2px 2px at 64% 84%, rgba(60,40,20,0.4), transparent 60%),
59
+ radial-gradient(2px 2px at 30% 92%, rgba(60,40,20,0.4), transparent 60%);
60
+ }
61
+ .wrap{position:relative;z-index:2;max-width:1320px;margin:0 auto;padding:34px 30px 70px;}
62
+
63
+ /* corner classification stamps */
64
+ .page-stamp{position:fixed;z-index:3;font-family:"Stardos Stencil",sans-serif;font-weight:700;
65
+ letter-spacing:3px;color:rgba(168,54,44,0.5);border:3px solid rgba(168,54,44,0.42);
66
+ padding:5px 12px;text-transform:uppercase;font-size:15px;pointer-events:none;
67
+ mix-blend-mode:screen;filter:contrast(1.1);}
68
+ .page-stamp.tl{top:26px;left:26px;transform:rotate(-9deg)}
69
+ .page-stamp.br{bottom:24px;right:30px;transform:rotate(6deg)}
70
+
71
+ /* ---------- header / emblem ---------- */
72
+ .hero{display:flex;flex-direction:column;align-items:center;gap:14px;margin-bottom:30px;}
73
+ .emblem{
74
+ position:relative;display:flex;align-items:center;gap:14px;
75
+ padding:12px 30px;border-radius:8px;
76
+ background:linear-gradient(180deg,#5a6038 0%, #2c3019 55%, #1a1d0f 100%);
77
+ border:1px solid #6c7344;
78
+ box-shadow:0 6px 18px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.18), inset 0 -3px 8px rgba(0,0,0,0.6);
79
+ }
80
+ .emblem::before,.emblem::after{content:"";position:absolute;top:50%;width:54px;height:14px;transform:translateY(-50%);
81
+ background:linear-gradient(180deg,#8d9258,#3d4222);clip-path:polygon(0 40%,100% 0,100% 100%,0 60%);opacity:0.9}
82
+ .emblem::before{left:-50px}
83
+ .emblem::after{right:-50px;transform:translateY(-50%) scaleX(-1)}
84
+ .emblem .star{color:#e9c45a;font-size:26px;text-shadow:0 1px 2px rgba(0,0,0,0.7);line-height:1}
85
+ .emblem .word{font-family:"Black Ops One",sans-serif;font-size:34px;letter-spacing:2px;line-height:1;
86
+ background:linear-gradient(180deg,#f3e3a8 0%, #c79a3e 48%, #8a6a22 52%, #e8d49a 100%);
87
+ -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
88
+ filter:drop-shadow(0 2px 1px rgba(0,0,0,0.6));}
89
+ .rivet{position:absolute;width:9px;height:9px;border-radius:50%;
90
+ background:radial-gradient(circle at 35% 30%, #cfd2c0, #6a6d52 55%, #1c1d12 100%);
91
+ box-shadow:0 1px 1px rgba(0,0,0,0.6), inset 0 1px 1px rgba(255,255,255,0.4);}
92
+ .emblem .rivet:nth-child(1){top:7px;left:9px}
93
+ .emblem .rivet:nth-child(2){top:7px;right:9px}
94
+ .emblem .rivet:nth-child(3){bottom:7px;left:9px}
95
+ .emblem .rivet:nth-child(4){bottom:7px;right:9px}
96
+ .subtitle{font-family:"Stardos Stencil",sans-serif;font-weight:700;font-size:26px;letter-spacing:11px;
97
+ color:var(--gold);text-transform:uppercase;padding-left:11px;
98
+ text-shadow:0 2px 4px rgba(0,0,0,0.6);text-align:center;}
99
+ .refline{font-family:"Saira Condensed";font-size:13px;letter-spacing:4px;color:var(--gold-dim);text-transform:uppercase;}
100
+
101
+ /* ---------- layout ---------- */
102
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:26px;align-items:start;}
103
+ .col{display:flex;flex-direction:column;gap:22px;}
104
+
105
+ /* ---------- riveted panel ---------- */
106
+ .panel{position:relative;border-radius:12px;padding:20px 22px 22px;
107
+ background:var(--panel);
108
+ border:1px solid var(--panel-edge);
109
+ box-shadow:0 10px 26px rgba(0,0,0,0.45), inset 0 1px 0 var(--panel-edge-hi), inset 0 -2px 10px rgba(0,0,0,0.5);
110
+ backdrop-filter:blur(1px);}
111
+ .panel::before,.panel::after,.panel .b3,.panel .b4{content:"";position:absolute;width:10px;height:10px;border-radius:50%;
112
+ background:radial-gradient(circle at 36% 30%, #c4c6b0, #5e6147 52%, #15170d 100%);
113
+ box-shadow:inset 0 1px 1px rgba(255,255,255,0.45), 0 1px 2px rgba(0,0,0,0.6);}
114
+ .panel::before{top:9px;left:9px}
115
+ .panel::after{top:9px;right:9px}
116
+ .panel .b3{bottom:9px;left:9px}
117
+ .panel .b4{bottom:9px;right:9px}
118
+ .phead{display:flex;align-items:center;gap:10px;margin:0 0 14px;padding:0 8px 11px;
119
+ border-bottom:1px solid rgba(199,176,116,0.18);}
120
+ .phead h2{margin:0;font-family:"Stardos Stencil",sans-serif;font-weight:700;font-size:18px;letter-spacing:4px;
121
+ color:var(--gold);text-transform:uppercase;}
122
+ .phead .tick{width:9px;height:9px;background:var(--orange);transform:rotate(45deg);box-shadow:0 0 8px rgba(214,132,31,0.6);flex:none;}
123
+
124
+ /* ---------- select ---------- */
125
+ .select{position:relative;}
126
+ .select select{appearance:none;-webkit-appearance:none;width:100%;padding:15px 46px 15px 18px;
127
+ font-family:"Saira Condensed";font-weight:600;font-size:18px;letter-spacing:0.5px;color:var(--text);
128
+ background:var(--inner);border:1px solid rgba(199,176,116,0.22);border-radius:8px;cursor:pointer;
129
+ box-shadow:inset 0 2px 8px rgba(0,0,0,0.6);}
130
+ .select select:focus{outline:1px solid var(--gold-dim);}
131
+ .select select option{background:var(--inner);color:var(--text);}
132
+ .select::after{content:"";position:absolute;right:18px;top:50%;transform:translateY(-30%);
133
+ border-left:7px solid transparent;border-right:7px solid transparent;border-top:8px solid var(--gold-dim);pointer-events:none;}
134
+
135
+ /* ---------- upload / evidence ---------- */
136
+ /* visually-hidden native file input (removes black artifact) */
137
+ .vh-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
138
+ clip:rect(0 0 0 0);white-space:nowrap;border:0;}
139
+
140
+ .dropzone{position:relative;min-height:300px;border:2px dashed rgba(199,176,116,0.32);border-radius:10px;
141
+ background:radial-gradient(120% 80% at 50% 30%, rgba(20,26,12,0.6), var(--inner));
142
+ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;cursor:pointer;
143
+ transition:border-color .2s, background .2s;}
144
+ .dropzone:hover{border-color:var(--orange);background:radial-gradient(120% 80% at 50% 30%, rgba(40,30,10,0.5), var(--inner));}
145
+ .reticle{position:relative;width:74px;height:74px;border-radius:50%;
146
+ border:2px solid var(--gold-dim);box-shadow:0 0 0 6px rgba(155,141,87,0.08), inset 0 0 0 1px rgba(155,141,87,0.25);}
147
+ .reticle::before,.reticle::after{content:"";position:absolute;background:var(--gold-dim);}
148
+ .reticle::before{left:50%;top:-12px;bottom:-12px;width:1.5px;transform:translateX(-50%);}
149
+ .reticle::after{top:50%;left:-12px;right:-12px;height:1.5px;transform:translateY(-50%);}
150
+ .reticle .dot{position:absolute;inset:0;margin:auto;width:9px;height:9px;border-radius:50%;background:var(--orange);box-shadow:0 0 10px var(--orange);}
151
+ .reticle .tk{position:absolute;width:7px;height:7px;border:2px solid var(--gold);}
152
+ .reticle .tk.a{top:6px;left:6px;border-right:0;border-bottom:0}
153
+ .reticle .tk.b{top:6px;right:6px;border-left:0;border-bottom:0}
154
+ .reticle .tk.c{bottom:6px;left:6px;border-right:0;border-top:0}
155
+ .reticle .tk.d{bottom:6px;right:6px;border-left:0;border-top:0}
156
+ .dz-main{font-family:"Saira Condensed";font-weight:600;font-size:20px;letter-spacing:1px;color:var(--text);text-align:center;padding:0 16px;}
157
+ .dz-sub{font-family:"Saira Condensed";font-size:14px;letter-spacing:3px;color:var(--text-dim);text-transform:uppercase;}
158
+
159
+ /* evidence frame (filled state) */
160
+ .evidence{display:none;cursor:pointer;}
161
+ .evidence.on{display:block;animation:fadein .35s ease;}
162
+ .ev-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;
163
+ background:linear-gradient(180deg,#1c2110,#0e1107);border:1px solid rgba(199,176,116,0.25);border-bottom:0;
164
+ border-radius:8px 8px 0 0;}
165
+ .ev-head .ttl{font-family:"Stardos Stencil";font-weight:700;letter-spacing:3px;font-size:14px;color:var(--gold);text-transform:uppercase;}
166
+ .ev-head .ref{font-family:"Saira Condensed";font-size:12px;letter-spacing:2px;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:50%;}
167
+ .ev-body{position:relative;padding:18px;background:repeating-linear-gradient(45deg,#0a0d06,#0a0d06 9px,#0d1108 9px,#0d1108 18px);
168
+ border:1px solid rgba(199,176,116,0.25);border-radius:0 0 8px 8px;overflow:hidden;}
169
+ .ev-doc{position:relative;border-radius:6px;box-shadow:0 14px 30px rgba(0,0,0,0.6);transform:rotate(-0.6deg);}
170
+ .ev-doc img{display:block;width:100%;height:auto;border-radius:6px;}
171
+ /* RECEIVED stamp + clip */
172
+ .stamp{position:absolute;font-family:"Stardos Stencil";font-weight:700;letter-spacing:3px;text-transform:uppercase;
173
+ color:var(--stamp-red);border:3px solid var(--stamp-red);padding:5px 11px;font-size:18px;
174
+ border-radius:4px;opacity:0.82;mix-blend-mode:multiply;filter:contrast(1.2);}
175
+ .stamp.received{right:14px;top:14px;transform:rotate(-13deg);z-index:5;}
176
+ .clip{position:absolute;left:50%;top:-9px;transform:translateX(-50%);width:30px;height:54px;border:4px solid #b9bcae;
177
+ border-radius:14px 14px 6px 6px;z-index:6;box-shadow:0 2px 4px rgba(0,0,0,0.5);}
178
+ .clip::after{content:"";position:absolute;inset:6px 6px 18px;border:3px solid #9a9d90;border-radius:9px 9px 0 0;}
179
+ @keyframes fadein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
180
+
181
+ /* scan-sweep overlay (loops over the document for the whole analysis) */
182
+ .scan{position:absolute;inset:18px;pointer-events:none;opacity:0;z-index:7;border-radius:6px;overflow:hidden;transform:rotate(-0.6deg);}
183
+ .scan.on{opacity:1;}
184
+ .scan .line{position:absolute;left:0;right:0;height:46px;top:-46px;
185
+ background:linear-gradient(180deg,transparent, rgba(120,255,150,0.0) 30%, rgba(120,255,150,0.28) 50%, rgba(120,255,150,0.0) 70%, transparent);
186
+ box-shadow:0 0 22px rgba(120,255,150,0.5);}
187
+ .scan.on .line{animation:sweep 1.7s linear infinite;}
188
+ .scan .grid-lines{position:absolute;inset:0;opacity:0.25;mix-blend-mode:screen;
189
+ background-image:linear-gradient(rgba(120,255,150,0.5) 1px,transparent 1px),linear-gradient(90deg,rgba(120,255,150,0.5) 1px,transparent 1px);
190
+ background-size:26px 26px;}
191
+ @keyframes sweep{0%{top:-46px}100%{top:100%}}
192
+
193
+ /* ---------- commence button ---------- */
194
+ .commence{position:relative;margin-top:4px;width:100%;border:0;cursor:pointer;border-radius:40px;padding:18px;
195
+ font-family:"Stardos Stencil";font-weight:700;font-size:21px;letter-spacing:6px;text-transform:uppercase;color:#3a2305;
196
+ background:linear-gradient(180deg,var(--orange-hi),var(--orange) 55%,var(--orange-dk));
197
+ box-shadow:0 8px 0 #6f3f0c, 0 12px 22px rgba(0,0,0,0.5), inset 0 2px 0 rgba(255,255,255,0.45);
198
+ transition:transform .07s, box-shadow .07s;}
199
+ .commence:hover:not(:disabled){filter:brightness(1.05)}
200
+ .commence:active:not(:disabled){transform:translateY(5px);box-shadow:0 3px 0 #6f3f0c, 0 6px 12px rgba(0,0,0,0.5), inset 0 2px 0 rgba(255,255,255,0.4);}
201
+ .commence:disabled{filter:grayscale(0.4) brightness(0.8);cursor:progress;}
202
+ .commence .ico{display:inline-block;width:14px;height:14px;border-radius:50%;border:3px solid #3a2305;vertical-align:middle;margin-right:12px;}
203
+
204
+ /* ---------- field report ---------- */
205
+ .report-idle{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;
206
+ min-height:200px;color:var(--text-dim);}
207
+ .report-idle .radar{position:relative;width:96px;height:96px;border-radius:50%;border:1px solid rgba(120,200,140,0.3);
208
+ background:radial-gradient(circle,rgba(60,120,70,0.16),transparent 70%);overflow:hidden;}
209
+ .report-idle .radar::before{content:"";position:absolute;inset:0;border-radius:50%;
210
+ background:conic-gradient(from 0deg, rgba(120,255,150,0.0) 0deg, rgba(120,255,150,0.35) 40deg, transparent 60deg);
211
+ animation:spin 2.6s linear infinite;}
212
+ .report-idle .radar::after{content:"";position:absolute;inset:0;margin:auto;width:5px;height:5px;border-radius:50%;
213
+ background:var(--teletype);box-shadow:0 0 8px var(--teletype);}
214
+ .report-idle .rings{position:absolute;inset:0;border-radius:50%;
215
+ background:radial-gradient(circle, transparent 30%, rgba(120,200,140,0.18) 31%, transparent 32%, transparent 60%, rgba(120,200,140,0.18) 61%, transparent 62%);}
216
+ .report-idle span{font-family:"Saira Condensed";letter-spacing:5px;text-transform:uppercase;font-size:14px;}
217
+ @keyframes spin{to{transform:rotate(360deg)}}
218
+
219
+ .report-body{font-family:"Special Elite",monospace;font-size:15px;line-height:1.62;color:var(--teletype);
220
+ min-height:200px;white-space:pre-wrap;word-break:break-word;letter-spacing:0.3px;
221
+ text-shadow:0 0 6px rgba(120,255,150,0.15);}
222
+ @keyframes report-pulse{0%,100%{opacity:1}50%{opacity:0.45}}
223
+ .report-body.pulse{color:var(--gold-bright);letter-spacing:1px;animation:report-pulse 1.2s ease-in-out infinite;}
224
+ .report-body.alert{color:#e0683c;text-shadow:none;}
225
+
226
+ /* ---------- resupply manifest ---------- */
227
+ .manifest-box{min-height:120px;font-family:"Saira Condensed";font-size:15px;}
228
+ .manifest-box .idle-m{font-style:italic;color:var(--text-dim);letter-spacing:2px;padding:10px 2px;}
229
+ #manifest ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:9px;}
230
+ #manifest li{padding:10px 13px;border-radius:9px;background:rgba(10,13,6,0.6);
231
+ border:1px solid rgba(199,176,116,0.16);box-shadow:inset 0 1px 0 rgba(255,255,255,0.05);
232
+ color:var(--text);line-height:1.4;}
233
+ #manifest a{color:var(--orange-hi);text-decoration:none;font-weight:600;}
234
+ #manifest a:hover{text-decoration:underline;}
235
+ #manifest p{color:var(--text-dim);font-style:italic;letter-spacing:2px;padding:10px 2px;}
236
+
237
+ /* ---------- inventory badge ---------- */
238
+ #manifest li.owned{border-color:rgba(100,160,80,0.30);background:rgba(8,14,6,0.75);}
239
+ .owned-badge{
240
+ display:inline-block;
241
+ font-family:"Stardos Stencil",sans-serif;font-weight:700;
242
+ font-size:10px;letter-spacing:2px;text-transform:uppercase;
243
+ color:#8ec98e;border:1px solid rgba(100,160,80,0.45);
244
+ padding:2px 7px;border-radius:3px;
245
+ background:rgba(30,50,18,0.7);
246
+ vertical-align:middle;margin-left:6px;
247
+ }
248
+ .owned-status{
249
+ display:block;margin-top:3px;
250
+ font-family:"Saira Condensed";font-size:11px;letter-spacing:2.5px;
251
+ color:rgba(142,201,142,0.55);text-transform:uppercase;
252
+ }
253
+
254
+ .footnote{margin-top:30px;text-align:center;font-family:"Stardos Stencil",sans-serif;font-weight:700;
255
+ font-size:16px;letter-spacing:3px;color:var(--gold);text-transform:uppercase;
256
+ text-shadow:0 1px 3px rgba(0,0,0,0.6);}
257
+ .footnote .heart{color:var(--stamp-red);text-transform:none;font-size:17px;
258
+ text-shadow:0 0 6px rgba(168,54,44,0.5);}
259
+
260
+ /* ---------- sample thumbnails ---------- */
261
+ .samples{margin-top:14px;text-align:center;}
262
+ .samples-label{font-family:"Saira Condensed";font-size:12px;letter-spacing:4px;text-transform:uppercase;color:var(--text-dim);}
263
+ .samples-groups{display:flex;gap:16px;justify-content:center;margin-top:8px;}
264
+ .smp-group{display:flex;flex-direction:column;align-items:center;gap:5px;}
265
+ .smp-group-label{font-family:"Saira Condensed";font-size:11px;letter-spacing:3px;text-transform:uppercase;color:var(--gold-dim);}
266
+ .samples-row{display:flex;gap:8px;}
267
+ .smp{width:68px;height:50px;background-size:cover;background-position:center;background-color:transparent;
268
+ border:1px solid rgba(199,176,116,0.28);border-radius:6px;cursor:pointer;
269
+ filter:sepia(0.5) brightness(0.85);transition:filter .18s,border-color .18s;padding:0;}
270
+ .smp:hover{filter:sepia(0) brightness(1);border-color:var(--orange);}
271
+ .smp:active{transform:scale(0.96);}
272
+
273
+ @media(prefers-reduced-motion:reduce){
274
+ .report-body.pulse{animation:none;}
275
+ .evidence.on{animation:none;}
276
+ .report-idle .radar::before{animation:none;}
277
+ .scan.on .line{animation:none;display:none;}
278
+ }
279
+ @media(max-width:880px){
280
+ .grid{grid-template-columns:1fr}
281
+ .subtitle{font-size:18px;letter-spacing:6px}
282
+ .page-stamp{display:none}
283
+ }
284
+ </style>
285
+ </head>
286
+ <body>
287
+ <div class="page-stamp tl">Classified</div>
288
+ <div class="page-stamp br">Eyes Only</div>
289
+
290
+ <div class="wrap">
291
+ <header class="hero">
292
+ <div class="emblem">
293
+ <span class="rivet"></span><span class="rivet"></span><span class="rivet"></span><span class="rivet"></span>
294
+ <span class="star">★</span>
295
+ <span class="word">PAINT MATCH</span>
296
+ </div>
297
+ <div class="subtitle">Colour Identification Unit</div>
298
+ <div class="refline">Ref · PM-1940-X &nbsp;//&nbsp; Sortie Log Active</div>
299
+ </header>
300
+
301
+ <div class="grid">
302
+ <!-- LEFT COLUMN -->
303
+ <div class="col">
304
+ <section class="panel">
305
+ <span class="b3"></span><span class="b4"></span>
306
+ <div class="phead"><span class="tick"></span><h2>Document Classification</h2></div>
307
+ <div class="select">
308
+ <select id="format">
309
+ <option value="airfix_instruction">Airfix paper instruction sheet</option>
310
+ <option value="airfix_shop">Airfix shop screenshot</option>
311
+ </select>
312
+ </div>
313
+ </section>
314
+
315
+ <section class="panel">
316
+ <span class="b3"></span><span class="b4"></span>
317
+ <div class="phead"><span class="tick"></span><h2>Upload Document</h2></div>
318
+
319
+ <input type="file" id="file" accept="image/*" class="vh-input">
320
+
321
+ <!-- empty state -->
322
+ <label class="dropzone" id="dropzone" for="file">
323
+ <div class="reticle">
324
+ <span class="dot"></span>
325
+ <span class="tk a"></span><span class="tk b"></span><span class="tk c"></span><span class="tk d"></span>
326
+ </div>
327
+ <div class="dz-main">Drop recon photo here or click to acquire</div>
328
+ <div class="dz-sub">JPG · PNG · WEBP — Max 10MB</div>
329
+ </label>
330
+
331
+ <!-- filled / evidence state -->
332
+ <label class="evidence" id="evidence" for="file">
333
+ <div class="ev-head">
334
+ <span class="ttl">Exhibit A — Recovered Document</span>
335
+ <span class="ref" id="src-name">SRC: —</span>
336
+ </div>
337
+ <div class="ev-body">
338
+ <div class="clip"></div>
339
+ <div class="ev-doc">
340
+ <img id="preview" alt="Recovered document">
341
+ <div class="stamp received">Received</div>
342
+ </div>
343
+ <div class="scan" id="scan"><div class="grid-lines"></div><div class="line"></div></div>
344
+ </div>
345
+ </label>
346
+
347
+ <div class="samples">
348
+ <span class="samples-label">— or load sample —</span>
349
+ <div class="samples-groups">
350
+ <div class="smp-group">
351
+ <span class="smp-group-label">Instructions</span>
352
+ <div class="samples-row">
353
+ <button class="smp"></button>
354
+ <button class="smp"></button>
355
+ </div>
356
+ </div>
357
+ <div class="smp-group">
358
+ <span class="smp-group-label">Shop</span>
359
+ <div class="samples-row">
360
+ <button class="smp"></button>
361
+ <button class="smp"></button>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ </section>
367
+
368
+ <button class="commence" id="analyze" type="button"><span class="ico"></span>Commence Analysis</button>
369
+ </div>
370
+
371
+ <!-- RIGHT COLUMN -->
372
+ <div class="col">
373
+ <section class="panel">
374
+ <span class="b3"></span><span class="b4"></span>
375
+ <div class="phead"><span class="tick"></span><h2>Field Report</h2></div>
376
+ <div class="report-idle" id="reportIdle">
377
+ <div class="radar"><div class="rings"></div></div>
378
+ <span>Standby — awaiting field report</span>
379
+ </div>
380
+ <div class="report-body" id="report" style="display:none"></div>
381
+ </section>
382
+
383
+ <section class="panel">
384
+ <span class="b3"></span><span class="b4"></span>
385
+ <div class="phead"><span class="tick"></span><h2>Resupply Manifest</h2></div>
386
+ <div class="manifest-box" id="manifest">
387
+ <div class="idle-m">Awaiting resupply manifest…</div>
388
+ </div>
389
+ </section>
390
+ </div>
391
+ </div>
392
+
393
+ <div class="footnote">— Built with <span class="heart">♥</span> in tribute to <a href="https://en.wikipedia.org/wiki/Airfix_Dogfighter" target="_blank" style="color:inherit;text-decoration:underline;text-underline-offset:3px;opacity:0.8;">Airfix&nbsp;Dogfighter</a> —</div>
394
+ </div>
395
+ <script type="module" src="app.js"></script>
396
+ </body>
397
+ </html>
inventory.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def _load() -> frozenset:
9
+ sa_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
10
+ sheet_id = os.environ.get("GOOGLE_SHEET_ID", "")
11
+ if not sa_json or not sheet_id:
12
+ logger.warning("inventory: env vars missing — inventory disabled")
13
+ return frozenset()
14
+ try:
15
+ import gspread
16
+ gc = gspread.service_account_from_dict(json.loads(sa_json))
17
+ ws = gc.open_by_key(sheet_id).sheet1
18
+ rows = ws.get_all_values()
19
+ codes = {row[0].strip().upper().replace("-", "") for row in rows[1:] if row and row[0].strip()}
20
+ logger.info("inventory: loaded %d codes", len(codes))
21
+ return frozenset(codes)
22
+ except Exception as exc:
23
+ logger.warning("inventory: failed to load (%s) — inventory disabled", exc)
24
+ return frozenset()
25
+
26
+
27
+ OWNED: frozenset = _load()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ requests
2
+ pillow
3
+ gspread
server.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import PIL.Image
3
+ import gradio as gr
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+ from starlette.responses import FileResponse
6
+
7
+ from app import analyze as _analyze # existing generator: yields (status, "") then (raw, manifest_html)
8
+
9
+ FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend")
10
+
11
+ app = gr.Server(title="Paint Match")
12
+
13
+
14
+ @app.api(name="analyze", stream_every=0.5)
15
+ def analyze(image: PIL.Image.Image, format_id: str) -> tuple[str, str]:
16
+ """Stream rotating status lines, then the final (field report, resupply manifest HTML).
17
+
18
+ gr.Server delivers `image` as a Gradio FileData dict (the PIL type hint is not
19
+ coerced). Accept both a dict (production: open the path) and a real PIL image
20
+ (so unit tests can pass one directly); None falls through to app.analyze's guard.
21
+ """
22
+ if isinstance(image, dict):
23
+ image = PIL.Image.open(image["path"])
24
+ yield from _analyze(image, format_id)
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Frontend middleware — intercepts /, /app.js, /assets/* BEFORE Gradio's
29
+ # routes win. Middleware runs before the router, so ordering of route
30
+ # registration does not matter.
31
+ # ---------------------------------------------------------------------------
32
+ _ASSETS_DIR = os.path.join(FRONTEND_DIR, "assets")
33
+ _EXAMPLES_DIR = os.path.join(os.path.dirname(__file__), "examples")
34
+
35
+ class FrontendMiddleware(BaseHTTPMiddleware):
36
+ async def dispatch(self, request, call_next):
37
+ path = request.url.path
38
+
39
+ if path == "/" or path == "":
40
+ return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
41
+
42
+ if path == "/app.js":
43
+ return FileResponse(
44
+ os.path.join(FRONTEND_DIR, "app.js"),
45
+ media_type="text/javascript",
46
+ )
47
+
48
+ if path.startswith("/assets/"):
49
+ asset_rel = path[len("/assets/"):]
50
+ assets_root = os.path.realpath(_ASSETS_DIR)
51
+ full = os.path.realpath(os.path.join(assets_root, asset_rel))
52
+ if (full == assets_root or full.startswith(assets_root + os.sep)) and os.path.isfile(full):
53
+ return FileResponse(full)
54
+
55
+ if path.startswith("/examples/"):
56
+ rel = path[len("/examples/"):]
57
+ examples_root = os.path.realpath(_EXAMPLES_DIR)
58
+ full = os.path.realpath(os.path.join(examples_root, rel))
59
+ if (full == examples_root or full.startswith(examples_root + os.sep)) and os.path.isfile(full):
60
+ return FileResponse(full)
61
+
62
+ return await call_next(request)
63
+
64
+
65
+ app.add_middleware(FrontendMiddleware)
66
+
67
+
68
+ if __name__ == "__main__":
69
+ app.launch(
70
+ server_name="0.0.0.0",
71
+ server_port=int(os.environ.get("PORT", "7860")),
72
+ allowed_paths=[FRONTEND_DIR],
73
+ )
tests/test_app.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
3
+
4
+ from app import tamiya_shop_url, _load_humbrol_tamiya
5
+
6
+
7
+ def test_url_xf_series():
8
+ assert tamiya_shop_url("XF17", "Sea Blue") == \
9
+ "https://www.mojehobby.pl/products/XF-17-Sea-Blue.html"
10
+
11
+
12
+ def test_url_x_series():
13
+ assert tamiya_shop_url("X5", "Green") == \
14
+ "https://www.mojehobby.pl/products/X-5-Green.html"
15
+
16
+
17
+ def test_url_multiword_name():
18
+ assert tamiya_shop_url("XF25", "Light Sea Grey") == \
19
+ "https://www.mojehobby.pl/products/XF-25-Light-Sea-Grey.html"
20
+
21
+
22
+ def test_url_dotted_name():
23
+ assert tamiya_shop_url("XF12", "J.N. Grey") == \
24
+ "https://www.mojehobby.pl/products/XF-12-JN-Grey.html"
25
+
26
+
27
+ def test_loader_returns_name():
28
+ table = _load_humbrol_tamiya()
29
+ code, name = table["104"]
30
+ assert code == "XF17"
31
+ assert name == "Sea Blue"
32
+
33
+
34
+ def test_loader_empty_tamiya_returns_empty_strings():
35
+ table = _load_humbrol_tamiya()
36
+ code, name = table["9"]
37
+ assert code == ""
38
+ assert name == ""
39
+
40
+
41
+ def test_loader_path_uses_data_dir():
42
+ """_load_humbrol_tamiya must read from data/ subdirectory."""
43
+ import inspect
44
+ source = inspect.getsource(_load_humbrol_tamiya)
45
+ assert '"data"' in source, "loader must reference data/ subdirectory"
46
+ table = _load_humbrol_tamiya()
47
+ assert len(table) > 100
48
+
49
+
50
+ def test_shop_format_parses_plain_code_list():
51
+ """Regression: the model returns a plain '- code: name' list even under the
52
+ shop format. The unified regex must parse it (the old shop regex matched none)."""
53
+ from app import FORMATS, strip_parens, parse_entries
54
+ raw = ("The paint codes from the product list are:\n\n"
55
+ "- 11: Silver\n- 33: Matt Black\n"
56
+ "- 118+34: Matt Desert Sand (4 parts 118, 3 parts 34)")
57
+ entries = parse_entries(strip_parens(raw), FORMATS["airfix_shop"]["regex"])
58
+ assert [e["code"] for e in entries] == ["11", "33", "118+34"]
59
+
60
+
61
+ def test_parser_handles_noisy_shop_output():
62
+ """Real shop-screenshot output: the model echoes the full product line and
63
+ appends a literal 'CODE: N - Name'. The tolerant parser must still extract
64
+ code+name and dedupe the repeated code on each line."""
65
+ from app import FORMATS, strip_parens, parse_entries, expand_codes
66
+ raw = (
67
+ "- Acrylic Paint 11 - Silver - Metallic - (14ml) - CODE: 11 - Silver\n"
68
+ "- Acrylic Paint 33 - Black - Matt - (14ml) - CODE: 33 - Black\n"
69
+ "- Acrylic Paint 168 - Hemp/Camouflage Beige - Matt - (14ml) - CODE: 168 - Hemp/Camouflage Beige"
70
+ )
71
+ expanded = expand_codes(parse_entries(strip_parens(raw), FORMATS["airfix_shop"]["regex"]))
72
+ by_code = {e["code"]: e["name"] for e in expanded}
73
+ assert by_code == {"11": "Silver", "33": "Black", "168": "Hemp/Camouflage Beige"}
74
+
75
+
76
+ def test_json_parser_extracts_code_name():
77
+ """json_schema mode returns a JSON array; parse_json_entries pulls code+name."""
78
+ from app import parse_json_entries
79
+ raw = '[{"code": "11", "name": "Silver"}, {"code": "118+34", "name": "Desert Sand"}]'
80
+ entries = parse_json_entries(raw)
81
+ assert [(e["code"], e["name"]) for e in entries] == \
82
+ [("11", "Silver"), ("118+34", "Desert Sand")]
83
+
84
+
85
+ def test_json_parser_drops_malformed_codes():
86
+ """Non-numeric / junk codes are filtered; a non-array yields nothing."""
87
+ from app import parse_json_entries
88
+ raw = '[{"code": "33", "name": "Black"}, {"code": "n/a"}, {"name": "no code"}]'
89
+ assert [e["code"] for e in parse_json_entries(raw)] == ["33"]
90
+ assert parse_json_entries('not json at all') == []
91
+ assert parse_json_entries('{"code": "11"}') == [] # object, not array
92
+
93
+
94
+ def test_json_parser_extracts_code_from_polluted_field():
95
+ """json_schema enforces shape but not semantics: the 2B model dumps the whole
96
+ echoed product line into `code`. The parser must pull the numeric code out."""
97
+ from app import parse_json_entries
98
+ raw = ('[{"code": "Acrylic Paint 11 - Silver - Metallic", "name": "Silver - Metallic"},'
99
+ ' {"code": "Acrylic Paint 118+34 - Desert Sand", "name": "Desert Sand"}]')
100
+ assert [e["code"] for e in parse_json_entries(raw)] == ["11", "118+34"]
101
+
102
+
103
+ def test_json_parser_feeds_expand_codes():
104
+ """The JSON path plugs into the shared downstream: composite split + dedupe."""
105
+ from app import parse_json_entries, expand_codes
106
+ raw = '[{"code": "118+34", "name": "Mix"}, {"code": "11", "name": "Silver"}]'
107
+ expanded = expand_codes(parse_json_entries(raw))
108
+ assert [e["code"] for e in expanded] == ["118", "34", "11"]
109
+
110
+
111
+ def test_is_paint_name_filters_decals_and_junk():
112
+ """Decal/stencil refs, bare numbers and years are not paints; colour names and
113
+ a missing name (code still matters) are kept."""
114
+ from app import is_paint_name
115
+ assert is_paint_name("Silver - Metallic") is True
116
+ assert is_paint_name("Gun Metal") is True
117
+ assert is_paint_name(None) is True
118
+ assert is_paint_name("") is True
119
+ assert is_paint_name("Decal No. 87 May 1991") is False
120
+ assert is_paint_name("Stencil 12") is False
121
+ assert is_paint_name("11") is False # name is just a number
122
+ assert is_paint_name("May 1991") is False # year
123
+
124
+
125
+ def test_extract_entries_drops_decal_entries():
126
+ """End-to-end on a JSON payload mimicking paper.JPG: decal/junk rows fall out,
127
+ real paint rows survive."""
128
+ import app
129
+ raw = ('[{"code": "87", "name": "Decal No. 87 May 1991"},'
130
+ ' {"code": "70", "name": "11"},'
131
+ ' {"code": "53", "name": "Gun Metal"},'
132
+ ' {"code": "11", "name": "Silver"}]')
133
+ orig = app.OUTPUT_MODE
134
+ app.OUTPUT_MODE = "json"
135
+ try:
136
+ entries = app.extract_entries(raw, app.CODE_REGEX)
137
+ finally:
138
+ app.OUTPUT_MODE = orig
139
+ assert [e["code"] for e in entries] == ["53", "11"]
140
+
141
+
142
+ def test_json_parser_salvages_truncated_array():
143
+ """max_tokens can cut the JSON mid-entry; the parser must still recover the
144
+ complete objects before the break instead of losing everything."""
145
+ from app import parse_json_entries
146
+ truncated = ('[\n {"code": "11", "name": "Silver"},\n'
147
+ ' {"code": "33", "name": "Black"},\n'
148
+ ' {"code": "Acrylic Paint') # cut off mid-entry, no closing ]
149
+ assert [e["code"] for e in parse_json_entries(truncated)] == ["11", "33"]
150
+
151
+
152
+ def test_meng_format_dropped():
153
+ """Meng is out of scope for the hackathon (codes don't map to Tamiya)."""
154
+ from app import FORMATS
155
+ assert "meng_color_reference" not in FORMATS
156
+
157
+
158
+ def test_analyze_final_yield_is_two_value_tuple():
159
+ """analyze() is a generator (status updates, then a final (raw_str, html_str))."""
160
+ from unittest.mock import patch
161
+ from PIL import Image
162
+ import numpy as np
163
+ from app import analyze
164
+
165
+ img = Image.fromarray(np.zeros((10, 10, 3), dtype=np.uint8))
166
+ with patch("app.call_llama", return_value="- 33: Flat Black\n- 98: Sky Grey"):
167
+ updates = list(analyze(img, "airfix_instruction"))
168
+
169
+ final = updates[-1]
170
+ assert isinstance(final, tuple)
171
+ assert len(final) == 2
172
+ raw, html = final
173
+ assert isinstance(raw, str)
174
+ assert isinstance(html, str)
175
+
176
+
177
+ def test_encode_image_applies_exif_orientation():
178
+ """Phone photos carry an EXIF orientation flag; encode_image must straighten
179
+ them. server.py opens raw files (PIL.Image.open) which bypass Gradio's own
180
+ exif_transpose, so the correction has to live in encode_image itself."""
181
+ import base64, io
182
+ from PIL import Image
183
+ from app import encode_image
184
+
185
+ # Portrait 100x200 stored sideways with orientation=6 (rotate to view as landscape).
186
+ img = Image.new("RGB", (100, 200), "white")
187
+ exif = img.getexif()
188
+ exif[274] = 6 # tag 274 = Orientation
189
+ buf = io.BytesIO()
190
+ img.save(buf, format="JPEG", exif=exif)
191
+ buf.seek(0)
192
+ reopened = Image.open(buf) # mirrors PIL.Image.open(path) in server.py
193
+
194
+ decoded = Image.open(io.BytesIO(base64.b64decode(encode_image(reopened))))
195
+ # After exif_transpose the displayed image is landscape: width > height.
196
+ assert decoded.width > decoded.height
197
+
198
+
199
+ def test_llama_server_defaults_to_lan(monkeypatch):
200
+ import importlib, app
201
+ monkeypatch.delenv("LLAMA_SERVER", raising=False)
202
+ importlib.reload(app)
203
+ assert app.LLAMA_SERVER == "http://192.168.68.119:8088"
204
+
205
+
206
+ def test_llama_server_reads_env(monkeypatch):
207
+ import importlib, app
208
+ monkeypatch.setenv("LLAMA_SERVER", "https://tunnel.example.com")
209
+ try:
210
+ importlib.reload(app)
211
+ assert app.LLAMA_SERVER == "https://tunnel.example.com"
212
+ finally:
213
+ monkeypatch.delenv("LLAMA_SERVER", raising=False)
214
+ importlib.reload(app)
215
+
216
+
217
+ def test_manifest_shows_badge_for_owned_paint(monkeypatch):
218
+ """When a Tamiya code is in OWNED, the manifest li must carry class='owned'."""
219
+ import inventory, app
220
+ monkeypatch.setattr(inventory, "OWNED", frozenset({"XF17"})) # no dash, matches lookup format
221
+
222
+ from unittest.mock import patch as mpatch
223
+ from PIL import Image
224
+ img = Image.new("RGB", (10, 10))
225
+
226
+ # JSON_SCHEMA produces a bare array. Humbrol 104 → XF17 Sea Blue.
227
+ fake_raw = '[{"code": "104", "name": "Sea Blue"}]'
228
+ with mpatch.object(app, "call_llama", return_value=fake_raw):
229
+ raw, html = app._run_analysis(img, "airfix_shop")
230
+
231
+ assert 'class="owned"' in html, f"Expected owned badge, got: {html}"
232
+ assert "mojehobby" not in html, f"Shop link should not appear for owned paint"
233
+
234
+
235
+ def test_manifest_shows_shop_link_for_unowned_paint(monkeypatch):
236
+ """When a Tamiya code is NOT in OWNED, the manifest li must contain a shop link."""
237
+ import inventory, app
238
+ monkeypatch.setattr(inventory, "OWNED", frozenset())
239
+
240
+ from unittest.mock import patch as mpatch
241
+ from PIL import Image
242
+ img = Image.new("RGB", (10, 10))
243
+
244
+ fake_raw = '[{"code": "104", "name": "Sea Blue"}]'
245
+ with mpatch.object(app, "call_llama", return_value=fake_raw):
246
+ raw, html = app._run_analysis(img, "airfix_shop")
247
+
248
+ assert "mojehobby" in html, f"Expected shop link, got: {html}"
249
+ assert 'class="owned"' not in html
tests/test_inventory.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
3
+
4
+ import importlib
5
+ from unittest.mock import MagicMock, patch
6
+
7
+
8
+ def make_sheet_rows(*codes):
9
+ return [[c] for c in codes]
10
+
11
+
12
+ def test_owned_is_frozenset():
13
+ import inventory
14
+ assert isinstance(inventory.OWNED, frozenset)
15
+
16
+
17
+ def test_owned_empty_when_env_missing(monkeypatch):
18
+ monkeypatch.delenv("GOOGLE_SERVICE_ACCOUNT_JSON", raising=False)
19
+ monkeypatch.delenv("GOOGLE_SHEET_ID", raising=False)
20
+ import inventory
21
+ importlib.reload(inventory)
22
+ assert inventory.OWNED == frozenset()
23
+
24
+
25
+ def test_owned_normalises_to_uppercase(monkeypatch):
26
+ monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_JSON", '{"type":"service_account"}')
27
+ monkeypatch.setenv("GOOGLE_SHEET_ID", "fake-id")
28
+ fake_ws = MagicMock()
29
+ fake_ws.get_all_values.return_value = make_sheet_rows("Kod", "x-25", "XF-24", " XF-2 ")
30
+ fake_gc = MagicMock()
31
+ fake_gc.open_by_key.return_value.sheet1 = fake_ws
32
+ with patch("gspread.service_account_from_dict", return_value=fake_gc):
33
+ import inventory
34
+ importlib.reload(inventory)
35
+ assert "X25" in inventory.OWNED
36
+ assert "XF24" in inventory.OWNED
37
+ assert "XF2" in inventory.OWNED
38
+
39
+
40
+ def test_owned_empty_on_auth_error(monkeypatch):
41
+ monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_JSON", '{"type":"service_account"}')
42
+ monkeypatch.setenv("GOOGLE_SHEET_ID", "fake-id")
43
+ with patch("gspread.service_account_from_dict", side_effect=Exception("auth failed")):
44
+ import inventory
45
+ importlib.reload(inventory)
46
+ assert inventory.OWNED == frozenset()
tests/test_server.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
3
+
4
+ import PIL.Image
5
+ import server
6
+
7
+
8
+ def test_analyze_opens_filedata_dict_and_returns_manifest(monkeypatch, tmp_path):
9
+ # gr.Server delivers the image as a FileData dict {"path": ...}, not a PIL image.
10
+ # This covers server.analyze's dict-unwrap branch (its only novel logic).
11
+ # Force the regex parser so the line-format mock parses regardless of the
12
+ # default OUTPUT_MODE — this test is about the dict unwrap, not the constraint.
13
+ monkeypatch.setattr("app.OUTPUT_MODE", "free")
14
+ monkeypatch.setattr(
15
+ "app.call_llama",
16
+ lambda img_b64, prompt: "- 33: Black",
17
+ )
18
+ img_path = tmp_path / "swatch.png"
19
+ PIL.Image.new("RGB", (32, 32), "white").save(img_path)
20
+
21
+ updates = list(server.analyze({"path": str(img_path)}, "airfix_shop"))
22
+
23
+ assert len(updates) >= 1
24
+ raw, manifest = updates[-1]
25
+ assert "33" in raw
26
+ assert "Humbrol 33" in manifest
27
+ assert manifest.startswith("<ul>")
28
+
29
+
30
+ def test_analyze_handles_no_image():
31
+ updates = list(server.analyze(None, "airfix_shop"))
32
+ assert updates[-1] == ("No image provided.", "")
ui.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+ import os
4
+ import gradio as gr
5
+ from PIL import Image
6
+
7
+ from app import analyze, FORMATS
8
+
9
+ # ---------- Assets ----------
10
+
11
+ def _load_asset_b64(filename: str, max_px: int | None = None) -> str:
12
+ path = os.path.join(os.path.dirname(__file__), "assets", filename)
13
+ if max_px:
14
+ img = Image.open(path)
15
+ if max(img.size) > max_px:
16
+ img.thumbnail((max_px, max_px))
17
+ buf = io.BytesIO()
18
+ fmt = "JPEG" if filename.lower().endswith((".jpg", ".jpeg")) else "PNG"
19
+ save_kw = {"quality": 85} if fmt == "JPEG" else {}
20
+ img.save(buf, format=fmt, **save_kw)
21
+ return base64.b64encode(buf.getvalue()).decode()
22
+ with open(path, "rb") as f:
23
+ return base64.b64encode(f.read()).decode()
24
+
25
+ _TEXTURE_B64 = _load_asset_b64("green_metal_rust_diff_4k.jpg", max_px=600)
26
+ _LOGO_B64 = _load_asset_b64("logo-kopia.png")
27
+
28
+ # ---------- CSS ----------
29
+
30
+ CSS = """
31
+ @import url('https://fonts.googleapis.com/css2?family=Barlow:wght@400;600;700&display=swap');
32
+
33
+ /* ── Theme overrides ── */
34
+ :root {
35
+ --body-background-fill: #c8b878;
36
+ --body-text-color: #a8b080;
37
+ --body-text-weight: 400;
38
+ --background-fill-primary: rgba(38,44,26,0.92);
39
+ --background-fill-secondary: rgba(28,34,16,0.96);
40
+ --block-background-fill: rgba(38,44,26,0.92);
41
+ --block-border-color: #5a6840;
42
+ --block-border-width: 1.5px;
43
+ --block-label-text-color: #b8c090;
44
+ --block-label-text-size: var(--text-sm);
45
+ --block-label-text-weight: 600;
46
+ --block-label-background-fill: linear-gradient(180deg, rgba(48,56,32,0.98) 0%, rgba(28,34,16,0.98) 100%);
47
+ --block-label-border-color: #485830;
48
+ --input-background-fill: rgba(18,24,10,0.78);
49
+ --input-border-color: rgba(70,90,45,0.55);
50
+ --neutral-950: rgba(18,24,10,0.99);
51
+ --neutral-900: rgba(22,28,14,0.99);
52
+ --neutral-800: rgba(28,34,18,0.97);
53
+ --neutral-700: rgba(35,42,22,0.95);
54
+ --button-primary-background-fill: linear-gradient(180deg, #e09030 0%, #b06010 55%, #904800 100%);
55
+ --button-primary-background-fill-hover: linear-gradient(180deg, #f0a040 0%, #c07020 55%, #a05800 100%);
56
+ --button-primary-border-color: #c07020;
57
+ --button-primary-border-color-hover: #d08030;
58
+ --button-primary-text-color: #fff8e0;
59
+ --button-primary-shadow: 0 4px 10px rgba(0,0,0,0.5);
60
+ }
61
+
62
+ /* ── Font ── */
63
+ *, *::before, *::after {
64
+ font-family: 'Barlow', sans-serif !important;
65
+ }
66
+
67
+ /* ── Page background (texture + fallback colour) ── */
68
+ body, gradio-app {
69
+ background-image: url('data:image/jpeg;base64,TEXTURE_DATA_URI') !important;
70
+ background-size: 400px !important;
71
+ background-repeat: repeat !important;
72
+ background-color: #c8b878 !important;
73
+ }
74
+
75
+ /* ── Max-width and centering ── */
76
+ .app.svelte-182fdeq, .app {
77
+ max-width: 900px !important;
78
+ margin: 0 auto !important;
79
+ padding: 16px !important;
80
+ }
81
+
82
+ /* ── Label uppercase + letter-spacing ── */
83
+ label > span, .block label span {
84
+ text-transform: uppercase !important;
85
+ letter-spacing: 2px !important;
86
+ }
87
+
88
+ /* ── Button pill ── */
89
+ #analyze-btn button {
90
+ border-radius: 26px !important;
91
+ font-size: 14px !important;
92
+ font-weight: 600 !important;
93
+ letter-spacing: 3px !important;
94
+ padding: 14px 0 !important;
95
+ width: 100% !important;
96
+ }
97
+
98
+ /* ── Header: transparent — elem_id is the direct element (container=False) ── */
99
+ #app-header {
100
+ background: transparent !important;
101
+ background-color: transparent !important;
102
+ border: none !important;
103
+ box-shadow: none !important;
104
+ padding: 0 !important;
105
+ }
106
+
107
+ /* ── Olive block backgrounds — correct container class for Gradio 6.16 ── */
108
+ .gradio-container-6-16-0 .block,
109
+ .gradio-container-6-16-0 .form {
110
+ background: rgba(38,44,26,0.92) !important;
111
+ }
112
+
113
+ /* ── Gradient label strips — correct selectors from Gradio 6 source ── */
114
+ label[data-testid="block-label"],
115
+ button.label-wrap {
116
+ background: linear-gradient(180deg, rgba(48,56,32,0.98) 0%, rgba(28,34,16,0.98) 100%) !important;
117
+ border-bottom: 1px solid #485830 !important;
118
+ }
119
+
120
+ /* ── Consistent label font ── */
121
+ label[data-testid="block-label"] span,
122
+ button.label-wrap > span:first-child {
123
+ font-size: 11px !important;
124
+ text-transform: uppercase !important;
125
+ letter-spacing: 2px !important;
126
+ font-weight: 600 !important;
127
+ color: #b8c090 !important;
128
+ }
129
+
130
+ /* ── Unified section headings (gr.HTML, one mechanism for all three) ── */
131
+ .sec-head {
132
+ color: #b8c090 !important;
133
+ font-size: 13px !important;
134
+ font-weight: 600 !important;
135
+ letter-spacing: 2px !important;
136
+ text-align: left !important;
137
+ padding: 2px 0 !important;
138
+ margin: 0 !important;
139
+ }
140
+
141
+ /* ── Dropdown options popup ── */
142
+ .options, ul[role="listbox"] {
143
+ background: rgba(28,34,16,0.98) !important;
144
+ border-color: #485830 !important;
145
+ }
146
+
147
+ /* ── Empty manifest: collapse when no content ── */
148
+ #manifest { min-height: 0 !important; padding: 0 !important; }
149
+
150
+ /* ── Manifest text (match Field report olive); links stay orange below ── */
151
+ #manifest, #manifest li {
152
+ color: #a8b080 !important;
153
+ }
154
+ /* ── Manifest list: plain lines (no bullets), comfortable rhythm ── */
155
+ #manifest ul {
156
+ list-style: none !important;
157
+ margin: 0 !important;
158
+ padding: 2px 0 !important;
159
+ }
160
+ #manifest li {
161
+ display: flex !important;
162
+ flex-wrap: wrap !important;
163
+ align-items: baseline !important;
164
+ gap: 0.3em !important;
165
+ line-height: 1.7 !important;
166
+ margin: 0 !important;
167
+ }
168
+ /* ── Field report: same looser line spacing ── */
169
+ #raw-output textarea {
170
+ line-height: 1.7 !important;
171
+ }
172
+ /* ── Manifest links ── */
173
+ #manifest a {
174
+ color: #e09030 !important;
175
+ margin: 0 !important;
176
+ padding: 0 !important;
177
+ }
178
+ .in-stock {
179
+ color: #a0c890 !important;
180
+ }
181
+
182
+ /* ── Input text colour ── */
183
+ input, textarea, select {
184
+ color: #a8b080 !important;
185
+ border-radius: 7px !important;
186
+ }
187
+
188
+ /* ── Mobile: stack columns vertically ── */
189
+ @media (max-width: 768px) {
190
+ .gap-4 { flex-direction: column !important; }
191
+ .gap-4 > * { width: 100% !important; min-width: 100% !important; }
192
+ }
193
+
194
+ /* ── Upload zone: custom emoji icon ── */
195
+ #img-upload .wrap::before {
196
+ content: "📂";
197
+ font-size: 30px;
198
+ display: block;
199
+ text-align: center;
200
+ padding: 14px 0 2px;
201
+ line-height: 1;
202
+ }
203
+ #img-upload .wrap svg { display: none !important; }
204
+ @media (max-width: 768px) {
205
+ #img-upload .wrap::before { content: "📸"; }
206
+ }
207
+ """
208
+ CSS = CSS.replace("TEXTURE_DATA_URI", _TEXTURE_B64)
209
+
210
+ # ---------- Header HTML ----------
211
+
212
+ HEADER_HTML = f"""
213
+ <link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;600;700&display=swap" rel="stylesheet">
214
+ <div style="text-align:center; padding: 20px 0 8px;">
215
+ <img src="data:image/png;base64,{_LOGO_B64}"
216
+ style="height:130px; width:auto; max-width:85%; display:block; margin:0 auto;"
217
+ alt="Paint Match">
218
+ <div style="font-family:'Barlow',sans-serif; color:#b0a878; font-size:13px; letter-spacing:5px; font-weight:700;
219
+ margin-top:-42px; position:relative; z-index:1; padding-bottom:20px;">
220
+ COLOUR IDENTIFICATION UNIT
221
+ </div>
222
+ </div>
223
+ """
224
+
225
+ # ---------- Layout ----------
226
+
227
+ with gr.Blocks(title="Paint Match") as demo:
228
+ gr.HTML(HEADER_HTML, elem_id="app-header")
229
+
230
+ with gr.Row():
231
+ with gr.Column():
232
+ with gr.Group():
233
+ gr.HTML("<div class='sec-head'>Document classification</div>")
234
+ format_in = gr.Dropdown(
235
+ choices=[(cfg["label"], fid) for fid, cfg in FORMATS.items()],
236
+ value="airfix_shop",
237
+ show_label=False,
238
+ elem_id="format-dropdown",
239
+ )
240
+ img_in = gr.Image(type="pil", label="Upload Document", elem_id="img-upload")
241
+ btn = gr.Button("◉ COMMENCE ANALYSIS", variant="primary", elem_id="analyze-btn")
242
+
243
+ with gr.Column():
244
+ with gr.Group():
245
+ gr.HTML("<div class='sec-head'>Field report</div>")
246
+ raw_out = gr.Textbox(
247
+ show_label=False,
248
+ lines=8,
249
+ elem_id="raw-output",
250
+ placeholder="Intelligence report — commence analysis to populate.",
251
+ )
252
+ with gr.Group():
253
+ gr.HTML("<div class='sec-head'>Resupply manifest</div>")
254
+ codes_out = gr.HTML(elem_id="manifest")
255
+
256
+ btn.click(
257
+ analyze,
258
+ inputs=[img_in, format_in],
259
+ outputs=[raw_out, codes_out],
260
+ show_progress="hidden",
261
+ )
262
+
263
+ if __name__ == "__main__":
264
+ os.environ.setdefault("GRADIO_DEFAULT_LOCALE", "en")
265
+ demo.launch(css=CSS, allowed_paths=["assets/"])