Spaces:
Sleeping
Sleeping
Commit ·
816f700
0
Parent(s):
chore: HF Space deployment — full pipeline + inventory
Browse filesSingle 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>
- CLAUDE.md +51 -0
- app.py +346 -0
- data/humbrol_tamyia.csv +172 -0
- frontend/app.js +111 -0
- frontend/index.html +397 -0
- inventory.py +27 -0
- requirements.txt +3 -0
- server.py +73 -0
- tests/test_app.py +249 -0
- tests/test_inventory.py +46 -0
- tests/test_server.py +32 -0
- 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">✓ 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 // 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 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/"])
|