| import base64 |
| import csv |
| import io |
| import itertools |
| import json |
| import os |
| import re |
| import threading |
| import time |
| import requests |
| from html import escape |
| from PIL import Image, ImageOps |
| import inventory |
|
|
| LLAMA_SERVER = os.environ.get("LLAMA_SERVER", "https://inference.llmops.pl") |
| TIMEOUT = 300 |
|
|
| |
|
|
| def _load_humbrol_tamiya() -> dict[str, tuple[str, str]]: |
| path = os.path.join(os.path.dirname(__file__), "data", "humbrol_tamyia.csv") |
| result: dict[str, tuple[str, str]] = {} |
| with open(path, newline="") as f: |
| for row in csv.DictReader(f): |
| h = row["humbrol"].strip() |
| t = row["tamiya"].strip() |
| n = row["tamiya_name"].strip() |
| if h and h not in result: |
| result[h] = (t, n) |
| return result |
|
|
| HUMBROL_TO_TAMIYA = _load_humbrol_tamiya() |
|
|
| |
|
|
| |
| |
| |
| LIST_PROMPT = ( |
| |
| "List all paint codes shown. 'code' = the numeric identifier (e.g. '33'). 'name' = colour name (e.g. 'Matt Black')" |
| |
| |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| CODE_REGEX = re.compile( |
| r'(?P<code>\d+(?:\+\d+)?)\s*[:\-]\s*(?P<name>[^-:\n]+?)\s*(?=[-–\n]|$)' |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| OUTPUT_MODE = "json" |
|
|
| |
| |
| |
| |
| |
| |
| JSON_SCHEMA = { |
| "type": "array", |
| "items": { |
| "type": "object", |
| "properties": { |
| "code": {"type": "string"}, |
| "name": {"type": "string"}, |
| }, |
| "required": ["code"], |
| }, |
| } |
|
|
| |
| |
| |
| |
| |
| LINE_GRAMMAR = r'''root ::= item+ |
| item ::= "- " code ": " name "\n" |
| code ::= [0-9]+ ("+" [0-9]+)? |
| name ::= [^\n]{1,40}''' |
|
|
| FORMATS = { |
| "airfix_shop": { |
| "label": "Airfix shop screenshot", |
| "prompt": LIST_PROMPT, |
| "regex": CODE_REGEX, |
| }, |
| "airfix_instruction": { |
| "label": "Airfix paper instruction sheet", |
| "prompt": LIST_PROMPT, |
| "regex": CODE_REGEX, |
| }, |
| } |
|
|
| |
|
|
| def encode_image(img: Image.Image) -> str: |
| """Correct EXIF orientation, resize to max 960px (longest side), encode as base64 JPEG.""" |
| img = ImageOps.exif_transpose(img) |
| img = img.convert("RGB") |
| |
| |
| |
| |
| img.thumbnail((960, 960)) |
| buf = io.BytesIO() |
| img.save(buf, format="JPEG", quality=90) |
| return base64.b64encode(buf.getvalue()).decode() |
|
|
|
|
| def call_llama(img_b64: str, prompt: str) -> str: |
| payload = { |
| "messages": [{ |
| "role": "user", |
| "content": [ |
| {"type": "text", "text": prompt}, |
| {"type": "image_url", |
| "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}} |
| ] |
| }], |
| "temperature": 0.0, |
| |
| |
| |
| "max_tokens": 1024, |
| |
| |
| |
| |
| "cache_prompt": False, |
| } |
| if OUTPUT_MODE == "json": |
| payload["response_format"] = { |
| "type": "json_schema", |
| "json_schema": {"name": "paint_codes", "schema": JSON_SCHEMA}, |
| } |
| elif OUTPUT_MODE == "line": |
| payload["grammar"] = LINE_GRAMMAR |
| payload["stream"] = True |
| r = requests.post( |
| f"{LLAMA_SERVER}/v1/chat/completions", |
| json=payload, |
| timeout=TIMEOUT, |
| stream=True, |
| ) |
| r.raise_for_status() |
| content = [] |
| for line in r.iter_lines(): |
| if not line: |
| continue |
| line = line.decode("utf-8") if isinstance(line, bytes) else line |
| if line.startswith("data: "): |
| data = line[6:] |
| if data.strip() == "[DONE]": |
| break |
| try: |
| chunk = json.loads(data) |
| delta = chunk["choices"][0]["delta"].get("content", "") |
| if delta: |
| content.append(delta) |
| except (json.JSONDecodeError, KeyError, IndexError): |
| pass |
| return "".join(content) |
|
|
| |
|
|
| def strip_parens(text: str) -> str: |
| return re.sub(r'\([^)]*\)', '', text) |
|
|
|
|
| def parse_entries(text: str, pattern: re.Pattern) -> list[dict]: |
| return [m.groupdict() for m in pattern.finditer(text)] |
|
|
|
|
| def _loads_tolerant(raw: str): |
| """json.loads, but salvage a truncated array (model hit max_tokens mid-entry). |
| A cut-off "[ {...}, {...}, {...incomplete" still has complete objects before |
| the break: keep through the last '}' and close the array. Returns None if even |
| that fails.""" |
| try: |
| return json.loads(raw) |
| except (json.JSONDecodeError, TypeError): |
| pass |
| start, end = raw.find("["), raw.rfind("}") |
| if start != -1 and end > start: |
| try: |
| return json.loads(raw[start:end + 1] + "]") |
| except json.JSONDecodeError: |
| return None |
| return None |
|
|
|
|
| def parse_json_entries(raw: str) -> list[dict]: |
| """Parse the json_schema-constrained array into {code, name} entries. |
| |
| json_schema enforces the array *shape* but not field semantics, so the 2B |
| model often dumps the whole echoed product line into `code` |
| ("Acrylic Paint 11 - Silver - Metallic"). We pull the first numeric code |
| (optionally a mix like "118+34") out of that field — reliable because it |
| operates on the already-isolated entry, not the raw response. Items with no |
| code-like token are dropped; a broken payload yields [].""" |
| data = _loads_tolerant(raw) |
| if not isinstance(data, list): |
| return [] |
| entries = [] |
| for item in data: |
| if not isinstance(item, dict): |
| continue |
| m = re.search(r'\d+(?:\+\d+)?', str(item.get("code", ""))) |
| if m: |
| entries.append({"code": m.group(0), "name": item.get("name")}) |
| return entries |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| NON_PAINT_NAME = re.compile(r'decal|stencil|transfer|\d{4}', re.IGNORECASE) |
|
|
|
|
| def is_paint_name(name) -> bool: |
| if name is None: |
| return True |
| s = str(name).strip() |
| if not s: |
| return True |
| if s.replace("+", "").isdigit(): |
| return False |
| return not NON_PAINT_NAME.search(s) |
|
|
|
|
| def extract_entries(raw: str, pattern: re.Pattern) -> list[dict]: |
| """Branch parsing on the active output mode (JSON for the json_schema |
| constraint, tolerant regex otherwise), then drop non-paint entries.""" |
| if OUTPUT_MODE == "json": |
| entries = parse_json_entries(raw) |
| else: |
| entries = parse_entries(strip_parens(raw), pattern) |
| result = [] |
| for e in entries: |
| name = e.get("name") |
| |
| if isinstance(name, str) and name.strip() == e.get("code", "").strip(): |
| e = {**e, "name": None} |
| if is_paint_name(e.get("name")): |
| result.append(e) |
| return result |
|
|
|
|
| def expand_codes(entries: list[dict]) -> list[dict]: |
| """Split composite codes (e.g. '118+34') into separate items; dedupe.""" |
| seen = set() |
| result = [] |
| for entry in entries: |
| code = entry["code"] |
| if "+" in code: |
| for c in code.split("+"): |
| if c not in seen: |
| seen.add(c) |
| result.append({"code": c, "name": None}) |
| else: |
| if code not in seen: |
| seen.add(code) |
| result.append(entry) |
| return result |
|
|
| |
|
|
| def tamiya_shop_url(code: str, name: str) -> str: |
| slug_code = re.sub(r'([A-Za-z]+)(\d+)', r'\1-\2', code) |
| slug_name = name.replace(".", "").replace(" ", "-") |
| return f"https://www.mojehobby.pl/products/{slug_code}-{slug_name}.html" |
|
|
|
|
| LOADING_INTERVAL = 8.0 |
|
|
| LOADING_LINES = [ |
| "▸ Establishing uplink to field unit…", |
| "▸ Calibrating optical sensors…", |
| "▸ Enhancing image resolution…", |
| "▸ Scanning paint tin labels…", |
| "▸ Identifying colour swatches…", |
| "▸ Cross-referencing Humbrol registry…", |
| "▸ Computing Tamiya equivalents…", |
| "▸ Consulting quartermaster database…", |
| "▸ Verifying stock availability…", |
| "▸ Assembling resupply manifest…", |
| ] |
|
|
|
|
| def _run_analysis(image: Image.Image, format_id: str) -> tuple[str, str]: |
| cfg = FORMATS[format_id] |
| try: |
| img_b64 = encode_image(image) |
| raw = call_llama(img_b64, cfg["prompt"]) |
| entries = extract_entries(raw, cfg["regex"]) |
| expanded = expand_codes(entries) |
| if expanded: |
| items = [] |
| for e in expanded: |
| code = e["code"] |
| tamiya_code, tamiya_name = HUMBROL_TO_TAMIYA.get(code, (None, "")) |
| if tamiya_code is None: |
| items.append(f"<li>Humbrol {escape(code)} – ?</li>") |
| elif tamiya_code == "": |
| items.append(f"<li>Humbrol {escape(code)} – (no Tamiya equivalent)</li>") |
| elif tamiya_name: |
| if tamiya_code.upper() in inventory.OWNED: |
| items.append( |
| f'<li class="owned">Humbrol {escape(code)} –' |
| f' Tamiya {tamiya_code} {escape(tamiya_name)}' |
| f' <span class="owned-badge">✓ ISSUED</span>' |
| f'<span class="owned-status">On personal charge — no resupply required</span></li>' |
| ) |
| else: |
| url = tamiya_shop_url(tamiya_code, tamiya_name) |
| items.append( |
| f'<li>Humbrol {escape(code)} –' |
| f' <a href="{url}" target="_blank">Tamiya {tamiya_code} {tamiya_name} ↗</a></li>' |
| ) |
| else: |
| items.append(f"<li>Humbrol {escape(code)} – Tamiya {tamiya_code}</li>") |
| codes_str = "<ul>" + "".join(items) + "</ul>" |
| else: |
| codes_str = "<p>(none parsed)</p>" |
| return raw, codes_str |
| except requests.Timeout: |
| return "Timeout — model took too long.", "" |
| except requests.RequestException as e: |
| return f"Server error: {e}", "" |
|
|
|
|
| def analyze(image: Image.Image, format_id: str): |
| """Generator: stream rotating status lines into Field report while inference |
| runs on a worker thread, then yield the final (raw report, manifest HTML).""" |
| if image is None: |
| yield "No image provided.", "" |
| return |
| box: dict[str, tuple[str, str]] = {} |
|
|
| def worker(): |
| try: |
| box["result"] = _run_analysis(image, format_id) |
| except Exception as e: |
| box["result"] = (f"Error: {e}", "") |
|
|
| t = threading.Thread(target=worker, daemon=True) |
| t.start() |
| for line in itertools.cycle(LOADING_LINES): |
| if not t.is_alive(): |
| break |
| yield line, "" |
| time.sleep(LOADING_INTERVAL) |
| t.join() |
| yield box.get("result", ("Unknown error.", "")) |
|
|