Initial Halide Space: pipeline, UI, autumn theme
Browse files- README.md +50 -0
- app.py +27 -0
- models/__init__.py +1 -0
- models/__pycache__/__init__.cpython-313.pyc +0 -0
- models/reasoning/.gitkeep +0 -0
- models/reasoning/__init__.py +1 -0
- models/reasoning/__pycache__/__init__.cpython-313.pyc +0 -0
- models/reasoning/__pycache__/nemotron_wrapper.cpython-313.pyc +0 -0
- models/reasoning/__pycache__/prompts.cpython-313.pyc +0 -0
- models/reasoning/inference.py +1 -0
- models/reasoning/nemotron_wrapper.py +96 -0
- models/reasoning/prompts.py +194 -0
- models/vision/.gitkeep +0 -0
- models/vision/__init__.py +1 -0
- models/vision/__pycache__/__init__.cpython-313.pyc +0 -0
- models/vision/__pycache__/inference.cpython-313.pyc +0 -0
- models/vision/__pycache__/minicpm_wrapper.cpython-313.pyc +0 -0
- models/vision/inference.py +78 -0
- models/vision/minicpm_wrapper.py +157 -0
- pipeline/.gitkeep +0 -0
- pipeline/__init__.py +1 -0
- pipeline/__pycache__/__init__.cpython-313.pyc +0 -0
- pipeline/__pycache__/diagnoser.cpython-313.pyc +0 -0
- pipeline/__pycache__/extractor.cpython-313.pyc +0 -0
- pipeline/__pycache__/pipeline.cpython-313.pyc +0 -0
- pipeline/diagnoser.py +60 -0
- pipeline/extractor.py +18 -0
- pipeline/pipeline.py +62 -0
- requirements.txt +16 -0
- storage/.gitkeep +0 -0
- storage/__init__.py +1 -0
- storage/__pycache__/__init__.cpython-313.pyc +0 -0
- storage/__pycache__/cache.cpython-313.pyc +0 -0
- storage/__pycache__/database.cpython-313.pyc +0 -0
- storage/cache.py +89 -0
- storage/database.py +177 -0
- storage/halide.db +0 -0
- ui/.gitkeep +0 -0
- ui/__init__.py +1 -0
- ui/__pycache__/__init__.cpython-313.pyc +0 -0
- ui/__pycache__/app.cpython-313.pyc +0 -0
- ui/__pycache__/components.cpython-313.pyc +0 -0
- ui/__pycache__/theme.cpython-313.pyc +0 -0
- ui/app.py +219 -0
- ui/components.py +104 -0
- ui/static/.gitkeep +0 -0
- ui/static/style.css +1 -0
- ui/theme.py +224 -0
README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Project Halide
|
| 3 |
+
emoji: "\U0001F525"
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.16.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
short_description: Edge-native diagnostic engine for analog film scans
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# Project Halide
|
| 15 |
+
|
| 16 |
+
An edge-native diagnostic engine for analog film. Upload a film scan, fill in
|
| 17 |
+
film stock + storage metadata, and Project Halide runs a two-stage analysis:
|
| 18 |
+
|
| 19 |
+
1. **Vision Extraction** -- MiniCPM-V 4.6 (1.3B params, fine-tuned with LoRA on
|
| 20 |
+
the FilmDamageSimulator dataset) detects dust, dirt, scratches, and hair
|
| 21 |
+
artifacts as normalized bounding boxes.
|
| 22 |
+
2. **Diagnostic Reasoning** -- Nemotron-Mini-4B-Instruct (4B params) with
|
| 23 |
+
3-shot prompting cross-references the defect report against your film stock
|
| 24 |
+
and storage metadata, and prescribes specific physical fixes a lab can
|
| 25 |
+
perform.
|
| 26 |
+
|
| 27 |
+
The full pipeline runs locally in the Space. No external APIs. Models are
|
| 28 |
+
loaded from a private Hugging Face repo at startup.
|
| 29 |
+
|
| 30 |
+
## How to use
|
| 31 |
+
|
| 32 |
+
1. Upload a film scan (PNG or JPEG, ideally 35mm or 120 frame).
|
| 33 |
+
2. Select your film stock from the dropdown.
|
| 34 |
+
3. Adjust the age and storage condition.
|
| 35 |
+
4. Pick the scan resolution you used.
|
| 36 |
+
5. Click **Diagnose scan**.
|
| 37 |
+
|
| 38 |
+
Results are stored in a local SQLite database. The "Recent diagnoses" panel
|
| 39 |
+
shows the last 10 runs in this Space session.
|
| 40 |
+
|
| 41 |
+
## Models
|
| 42 |
+
|
| 43 |
+
- Vision: `Lonelyguyse1/halide-vision` (private), based on
|
| 44 |
+
`openbmb/MiniCPM-V-4_6`.
|
| 45 |
+
- Reasoning: `nvidia/Nemotron-Mini-4B-Instruct` (public, 4B params, few-shot
|
| 46 |
+
prompting only, no fine-tuning).
|
| 47 |
+
|
| 48 |
+
## License
|
| 49 |
+
|
| 50 |
+
Apache 2.0.
|
app.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main entry point. Launches the Gradio app."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from ui.app import build_app
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main() -> None:
|
| 11 |
+
logging.basicConfig(
|
| 12 |
+
level=logging.INFO,
|
| 13 |
+
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 14 |
+
)
|
| 15 |
+
from ui.theme import THEME_CSS, build_theme
|
| 16 |
+
app = build_app()
|
| 17 |
+
app.queue(max_size=8).launch(
|
| 18 |
+
server_name="0.0.0.0",
|
| 19 |
+
server_port=7860,
|
| 20 |
+
show_error=True,
|
| 21 |
+
theme=build_theme(),
|
| 22 |
+
css=THEME_CSS,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
if __name__ == "__main__":
|
| 27 |
+
main()
|
models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Model package. Vision and reasoning model wrappers."""
|
models/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (239 Bytes). View file
|
|
|
models/reasoning/.gitkeep
ADDED
|
File without changes
|
models/reasoning/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Reasoning model package. Nemotron-Mini wrapper for diagnosis."""
|
models/reasoning/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (259 Bytes). View file
|
|
|
models/reasoning/__pycache__/nemotron_wrapper.cpython-313.pyc
ADDED
|
Binary file (4.65 kB). View file
|
|
|
models/reasoning/__pycache__/prompts.cpython-313.pyc
ADDED
|
Binary file (6.27 kB). View file
|
|
|
models/reasoning/inference.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Reasoning inference pipeline. Takes defect JSON and returns diagnosis."""
|
models/reasoning/nemotron_wrapper.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Nemotron-Mini-4B wrapper. Loads the model and generates diagnoses.
|
| 2 |
+
|
| 3 |
+
Per AGENTS.md, this is the second stage of the dual-model pipeline.
|
| 4 |
+
Receives defect JSON from the vision model plus user metadata, returns
|
| 5 |
+
root cause diagnosis and physical remediation steps.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
NEMOTRON_MODEL_ID = "nvidia/Nemotron-Mini-4B-Instruct"
|
| 17 |
+
MAX_NEW_TOKENS = int(os.getenv("HALIDE_NEMOTRON_MAX_TOKENS", "512"))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class NemotronReasoner:
|
| 21 |
+
"""Lazy-loading wrapper around Nemotron-Mini-4B-Instruct."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, model_path: str | None = None) -> None:
|
| 24 |
+
self._model_path = model_path or NEMOTRON_MODEL_ID
|
| 25 |
+
self._tokenizer: Any = None
|
| 26 |
+
self._model: Any = None
|
| 27 |
+
self._device: str = "cpu"
|
| 28 |
+
self._dtype: Any = None
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def model_path(self) -> str:
|
| 32 |
+
return self._model_path
|
| 33 |
+
|
| 34 |
+
def load(self) -> None:
|
| 35 |
+
if self._model is not None:
|
| 36 |
+
return
|
| 37 |
+
import torch
|
| 38 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 39 |
+
|
| 40 |
+
logger.info("Loading Nemotron-Mini-4B from %s", self._model_path)
|
| 41 |
+
self._tokenizer = AutoTokenizer.from_pretrained(self._model_path)
|
| 42 |
+
self._dtype = torch.bfloat16
|
| 43 |
+
self._model = AutoModelForCausalLM.from_pretrained(
|
| 44 |
+
self._model_path,
|
| 45 |
+
torch_dtype=self._dtype,
|
| 46 |
+
device_map="auto",
|
| 47 |
+
)
|
| 48 |
+
self._device = str(next(self._model.parameters()).device)
|
| 49 |
+
logger.info("Nemotron loaded on %s", self._device)
|
| 50 |
+
|
| 51 |
+
def generate(self, prompt: str, system: str | None = None) -> str:
|
| 52 |
+
if self._model is None:
|
| 53 |
+
self.load()
|
| 54 |
+
|
| 55 |
+
import torch
|
| 56 |
+
|
| 57 |
+
if system:
|
| 58 |
+
messages = [
|
| 59 |
+
{"role": "system", "content": system},
|
| 60 |
+
{"role": "user", "content": prompt},
|
| 61 |
+
]
|
| 62 |
+
else:
|
| 63 |
+
messages = [{"role": "user", "content": prompt}]
|
| 64 |
+
|
| 65 |
+
input_ids = self._tokenizer.apply_chat_template(
|
| 66 |
+
messages, add_generation_prompt=True, return_tensors="pt"
|
| 67 |
+
).to(self._device)
|
| 68 |
+
|
| 69 |
+
with torch.inference_mode():
|
| 70 |
+
output = self._model.generate(
|
| 71 |
+
input_ids,
|
| 72 |
+
max_new_tokens=MAX_NEW_TOKENS,
|
| 73 |
+
do_sample=False,
|
| 74 |
+
pad_token_id=self._tokenizer.eos_token_id,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
response_ids = output[0][input_ids.shape[-1]:]
|
| 78 |
+
return self._tokenizer.decode(response_ids, skip_special_tokens=True)
|
| 79 |
+
|
| 80 |
+
def close(self) -> None:
|
| 81 |
+
if self._model is not None:
|
| 82 |
+
del self._model
|
| 83 |
+
self._model = None
|
| 84 |
+
if self._tokenizer is not None:
|
| 85 |
+
del self._tokenizer
|
| 86 |
+
self._tokenizer = None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
_default_reasoner: NemotronReasoner | None = None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_reasoner() -> NemotronReasoner:
|
| 93 |
+
global _default_reasoner
|
| 94 |
+
if _default_reasoner is None:
|
| 95 |
+
_default_reasoner = NemotronReasoner()
|
| 96 |
+
return _default_reasoner
|
models/reasoning/prompts.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Few-shot prompt templates for the Nemotron diagnostic reasoner."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
SYSTEM_PROMPT = (
|
| 9 |
+
"You are a senior analog film lab technician with 30 years of experience "
|
| 10 |
+
"in darkroom printing, negative inspection, and equipment repair. You are "
|
| 11 |
+
"diagnosing the physical root cause of degradation in a film scan and "
|
| 12 |
+
"prescribing specific, actionable physical fixes a lab can perform."
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
FEW_SHOT_EXAMPLES: list[dict[str, str]] = [
|
| 17 |
+
{
|
| 18 |
+
"role": "user",
|
| 19 |
+
"content": (
|
| 20 |
+
"## Defect report\n"
|
| 21 |
+
"{\n"
|
| 22 |
+
' "film_type": "Kodak Portra 400 (35mm)",\n'
|
| 23 |
+
' "film_age_years": 2,\n'
|
| 24 |
+
' "storage": "fridge, sealed",\n'
|
| 25 |
+
' "defects": [\n'
|
| 26 |
+
' {"label": "dust", "bbox_count": 87},\n'
|
| 27 |
+
' {"label": "dirt", "bbox_count": 12}\n'
|
| 28 |
+
' ],\n'
|
| 29 |
+
' "scan_resolution_dpi": 4000\n'
|
| 30 |
+
"}\n\n"
|
| 31 |
+
"What is the root cause and what physical fixes do you recommend?"
|
| 32 |
+
),
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"role": "assistant",
|
| 36 |
+
"content": (
|
| 37 |
+
"## Root cause\n"
|
| 38 |
+
"High dust and dirt count on a recently-shot, properly stored roll "
|
| 39 |
+
"indicates contamination accumulated on the negative during "
|
| 40 |
+
"scanning, not deterioration of the film itself. The scanner's "
|
| 41 |
+
"dust-removal hardware (ICE/DEEP) is either disabled or "
|
| 42 |
+
"ineffective at 4000 dpi.\n\n"
|
| 43 |
+
"## Physical fixes\n"
|
| 44 |
+
"1. Clean the scanner glass and film holder with an anti-static "
|
| 45 |
+
"cloth and PEC*PAD swabs; re-scan.\n"
|
| 46 |
+
"2. Enable hardware-based dust removal in the scanner software "
|
| 47 |
+
"(e.g., Digital ICE for Kodak, DEEP for Noritsu) at a moderate "
|
| 48 |
+
"strength (level 3 of 5).\n"
|
| 49 |
+
"3. If hardware removal is unavailable, apply careful digital "
|
| 50 |
+
"spotting in Photoshop using the healing brush; budget 30-60 "
|
| 51 |
+
"minutes per frame.\n"
|
| 52 |
+
"4. Inspect the original negative under a loupe for any residual "
|
| 53 |
+
"physical particles and gently blow off with a rocket air blower "
|
| 54 |
+
"before re-scanning."
|
| 55 |
+
),
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"role": "user",
|
| 59 |
+
"content": (
|
| 60 |
+
"## Defect report\n"
|
| 61 |
+
"{\n"
|
| 62 |
+
' "film_type": "Ilford HP5 (120 medium format)",\n'
|
| 63 |
+
' "film_age_years": 15,\n'
|
| 64 |
+
' "storage": "shoe box, attic",\n'
|
| 65 |
+
' "defects": [\n'
|
| 66 |
+
' {"label": "long_hair", "bbox_count": 4},\n'
|
| 67 |
+
' {"label": "scratch", "bbox_count": 1}\n'
|
| 68 |
+
' ],\n'
|
| 69 |
+
' "scan_resolution_dpi": 3200\n'
|
| 70 |
+
"}\n\n"
|
| 71 |
+
"What is the root cause and what physical fixes do you recommend?"
|
| 72 |
+
),
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"role": "assistant",
|
| 76 |
+
"content": (
|
| 77 |
+
"## Root cause\n"
|
| 78 |
+
"Long hair and scratches on an aged, poorly stored roll indicate "
|
| 79 |
+
"physical abrasion from dust and debris inside the storage "
|
| 80 |
+
"container. Attic storage subjects film to temperature and "
|
| 81 |
+
"humidity swings that warp the emulsion and make it brittle.\n\n"
|
| 82 |
+
"## Physical fixes\n"
|
| 83 |
+
"1. Inspect the negative under a 10x loupe to confirm hair is on "
|
| 84 |
+
"the emulsion side (matte) vs. base side (glossy). Emulsion-side "
|
| 85 |
+
"hair cannot be wet-wiped safely.\n"
|
| 86 |
+
"2. If base-side only: gently wipe with a PEC*PAD soaked in "
|
| 87 |
+
"Edwal Negative Cleaner, then PEC*PAD with distilled water, "
|
| 88 |
+
"then dry.\n"
|
| 89 |
+
"3. If emulsion-side: do not wipe. Use Print File archival "
|
| 90 |
+
"sleeves and ship to a lab with wet-mount scanning (e.g., "
|
| 91 |
+
"Northcoast Photographic).\n"
|
| 92 |
+
"4. The single scratch is most likely from a dirty film "
|
| 93 |
+
"transport. Have the scanner cleaned and the film path "
|
| 94 |
+
"inspected; consider wet-gate scanning to mask the scratch.\n"
|
| 95 |
+
"5. Long-term: transfer remaining unexposed rolls to a "
|
| 96 |
+
"freezer at -18C in sealed bags with desiccant."
|
| 97 |
+
),
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"role": "user",
|
| 101 |
+
"content": (
|
| 102 |
+
"## Defect report\n"
|
| 103 |
+
"{\n"
|
| 104 |
+
' "film_type": "CineStill 800T (35mm)",\n'
|
| 105 |
+
' "film_age_years": 0,\n'
|
| 106 |
+
' "storage": "fresh, refrigerated by manufacturer",\n'
|
| 107 |
+
' "defects": [\n'
|
| 108 |
+
' {"label": "dust", "bbox_count": 156},\n'
|
| 109 |
+
' {"label": "dirt", "bbox_count": 23},\n'
|
| 110 |
+
' {"label": "short_hair", "bbox_count": 8}\n'
|
| 111 |
+
' ],\n'
|
| 112 |
+
' "scan_resolution_dpi": 6000\n'
|
| 113 |
+
"}\n\n"
|
| 114 |
+
"What is the root cause and what physical fixes do you recommend?"
|
| 115 |
+
),
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"role": "assistant",
|
| 119 |
+
"content": (
|
| 120 |
+
"## Root cause\n"
|
| 121 |
+
"Excessive dust and dirt on a brand-new refrigerated roll at 6000 "
|
| 122 |
+
"dpi scan suggests a combination of (a) the static-prone "
|
| 123 |
+
"CineStill base attracting particles during loading, and (b) "
|
| 124 |
+
"scanner-side contamination. The hair count points to the "
|
| 125 |
+
"scanner's film transport path.\n\n"
|
| 126 |
+
"## Physical fixes\n"
|
| 127 |
+
"1. Anti-static treatment is critical for CineStill: discharge "
|
| 128 |
+
"the negative with a Zerostat gun on low setting 30 cm from the "
|
| 129 |
+
"film before scanning.\n"
|
| 130 |
+
"2. Clean the scanner glass, film holder, and feed rollers with "
|
| 131 |
+
"PEC*PAD swabs and reagent-grade isopropyl alcohol.\n"
|
| 132 |
+
"3. Use a static-discharge ionizing bar (e.g., Simco-Ion) at the "
|
| 133 |
+
"scanner input if available.\n"
|
| 134 |
+
"4. Re-scan with hardware dust removal at level 4 of 5. "
|
| 135 |
+
"CineStill's halated emulsion responds well to Digital ICE.\n"
|
| 136 |
+
"5. For the short hairs, inspect the film path under magnification "
|
| 137 |
+
"and remove any visible lint from the rollers with tweezers."
|
| 138 |
+
),
|
| 139 |
+
},
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def build_user_prompt(
|
| 144 |
+
film_type: str,
|
| 145 |
+
film_age_years: int,
|
| 146 |
+
storage: str,
|
| 147 |
+
scan_resolution_dpi: int,
|
| 148 |
+
defect_summary: dict[str, int],
|
| 149 |
+
total_defects: int,
|
| 150 |
+
) -> str:
|
| 151 |
+
"""Build the user message for the current diagnosis request."""
|
| 152 |
+
payload = {
|
| 153 |
+
"film_type": film_type,
|
| 154 |
+
"film_age_years": film_age_years,
|
| 155 |
+
"storage": storage,
|
| 156 |
+
"defects": [
|
| 157 |
+
{"label": label, "bbox_count": count}
|
| 158 |
+
for label, count in sorted(defect_summary.items())
|
| 159 |
+
],
|
| 160 |
+
"scan_resolution_dpi": scan_resolution_dpi,
|
| 161 |
+
"total_defect_count": total_defects,
|
| 162 |
+
}
|
| 163 |
+
return (
|
| 164 |
+
"## Defect report\n"
|
| 165 |
+
f"{json.dumps(payload, indent=2)}\n\n"
|
| 166 |
+
"What is the root cause and what physical fixes do you recommend?"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def build_messages(
|
| 171 |
+
film_type: str,
|
| 172 |
+
film_age_years: int,
|
| 173 |
+
storage: str,
|
| 174 |
+
scan_resolution_dpi: int,
|
| 175 |
+
defect_summary: dict[str, int],
|
| 176 |
+
total_defects: int,
|
| 177 |
+
) -> list[dict[str, str]]:
|
| 178 |
+
"""Return full message list (few-shot + current request) for the reasoner."""
|
| 179 |
+
messages: list[dict[str, str]] = []
|
| 180 |
+
messages.extend(FEW_SHOT_EXAMPLES)
|
| 181 |
+
messages.append(
|
| 182 |
+
{
|
| 183 |
+
"role": "user",
|
| 184 |
+
"content": build_user_prompt(
|
| 185 |
+
film_type,
|
| 186 |
+
film_age_years,
|
| 187 |
+
storage,
|
| 188 |
+
scan_resolution_dpi,
|
| 189 |
+
defect_summary,
|
| 190 |
+
total_defects,
|
| 191 |
+
),
|
| 192 |
+
}
|
| 193 |
+
)
|
| 194 |
+
return messages
|
models/vision/.gitkeep
ADDED
|
File without changes
|
models/vision/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Vision model package. MiniCPM-V 4.6 wrapper for film defect detection."""
|
models/vision/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (265 Bytes). View file
|
|
|
models/vision/__pycache__/inference.cpython-313.pyc
ADDED
|
Binary file (3.49 kB). View file
|
|
|
models/vision/__pycache__/minicpm_wrapper.cpython-313.pyc
ADDED
|
Binary file (7.74 kB). View file
|
|
|
models/vision/inference.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vision inference pipeline. Takes a film scan and returns defect JSON."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from models.vision.minicpm_wrapper import get_detector
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
ALLOWED_LABELS = {"dust", "dirt", "scratch", "long_hair", "short_hair"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def extract_defects(image: Any) -> dict:
|
| 18 |
+
"""Run defect extraction on a PIL image. Returns defect dict + metadata."""
|
| 19 |
+
started = time.perf_counter()
|
| 20 |
+
detector = get_detector()
|
| 21 |
+
raw = detector.detect(image)
|
| 22 |
+
elapsed = time.perf_counter() - started
|
| 23 |
+
|
| 24 |
+
defects = raw.get("defects", [])
|
| 25 |
+
if not isinstance(defects, list):
|
| 26 |
+
logger.warning("Model output 'defects' is not a list: %r", type(defects))
|
| 27 |
+
defects = []
|
| 28 |
+
|
| 29 |
+
cleaned: list[dict] = []
|
| 30 |
+
dropped = 0
|
| 31 |
+
for d in defects:
|
| 32 |
+
if not isinstance(d, dict):
|
| 33 |
+
dropped += 1
|
| 34 |
+
continue
|
| 35 |
+
label = d.get("label")
|
| 36 |
+
bbox = d.get("bbox")
|
| 37 |
+
if label not in ALLOWED_LABELS:
|
| 38 |
+
dropped += 1
|
| 39 |
+
continue
|
| 40 |
+
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
| 41 |
+
dropped += 1
|
| 42 |
+
continue
|
| 43 |
+
try:
|
| 44 |
+
x_min, y_min, x_max, y_max = (float(v) for v in bbox)
|
| 45 |
+
except (TypeError, ValueError):
|
| 46 |
+
dropped += 1
|
| 47 |
+
continue
|
| 48 |
+
if not (0.0 <= x_min <= 1.0 and 0.0 <= y_min <= 1.0):
|
| 49 |
+
dropped += 1
|
| 50 |
+
continue
|
| 51 |
+
if not (0.0 <= x_max <= 1.0 and 0.0 <= y_max <= 1.0):
|
| 52 |
+
dropped += 1
|
| 53 |
+
continue
|
| 54 |
+
if x_max <= x_min or y_max <= y_min:
|
| 55 |
+
dropped += 1
|
| 56 |
+
continue
|
| 57 |
+
cleaned.append({"label": label, "bbox": [x_min, y_min, x_max, y_max]})
|
| 58 |
+
|
| 59 |
+
label_counts: dict[str, int] = {}
|
| 60 |
+
for d in cleaned:
|
| 61 |
+
label_counts[d["label"]] = label_counts.get(d["label"], 0) + 1
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
"defects": cleaned,
|
| 65 |
+
"defect_count": len(cleaned),
|
| 66 |
+
"label_counts": label_counts,
|
| 67 |
+
"dropped_count": dropped,
|
| 68 |
+
"inference_seconds": round(elapsed, 3),
|
| 69 |
+
"model_path": detector.model_path,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def extract_defects_from_path(image_path: str | Path) -> dict:
|
| 74 |
+
"""Convenience: open image from path and run extraction."""
|
| 75 |
+
from PIL import Image
|
| 76 |
+
|
| 77 |
+
img = Image.open(image_path).convert("RGB")
|
| 78 |
+
return extract_defects(img)
|
models/vision/minicpm_wrapper.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MiniCPM-V 4.6 wrapper. Loads the model and runs inference on film scans."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 15 |
+
LOCAL_MODEL_PATH = REPO_ROOT / "checkpoints" / "minicpm-v-4.6-merged"
|
| 16 |
+
HF_MODEL_ID = "Lonelyguyse1/halide-vision"
|
| 17 |
+
BASE_MODEL_ID = "openbmb/MiniCPM-V-4_6"
|
| 18 |
+
|
| 19 |
+
DOWNSAMPLE_MODE = os.getenv("HALIDE_DOWNSAMPLE_MODE", "4x")
|
| 20 |
+
MAX_SLICE_NUMS = int(os.getenv("HALIDE_MAX_SLICE_NUMS", "36"))
|
| 21 |
+
MAX_NEW_TOKENS = int(os.getenv("HALIDE_MAX_NEW_TOKENS", "3072"))
|
| 22 |
+
|
| 23 |
+
DETECTION_PROMPT = (
|
| 24 |
+
"You are a film defect detection engine. Analyze the film scan and detect "
|
| 25 |
+
"all visible defects. Output a JSON object with a 'defects' array. Each "
|
| 26 |
+
"defect has: 'label' (dust, dirt, scratch, long_hair, short_hair), 'bbox' "
|
| 27 |
+
"(normalized [x_min, y_min, x_max, y_max] from 0.0 to 1.0). Output JSON "
|
| 28 |
+
"only, no explanation."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _resolve_model_path() -> str:
|
| 33 |
+
"""Pick local merged model if present, else HF repo, else base model id."""
|
| 34 |
+
if LOCAL_MODEL_PATH.exists() and (LOCAL_MODEL_PATH / "config.json").exists():
|
| 35 |
+
logger.info("Using local merged model at %s", LOCAL_MODEL_PATH)
|
| 36 |
+
return str(LOCAL_MODEL_PATH)
|
| 37 |
+
if os.getenv("HF_TOKEN"):
|
| 38 |
+
logger.info("Using HF Hub repo %s", HF_MODEL_ID)
|
| 39 |
+
return HF_MODEL_ID
|
| 40 |
+
logger.info("Falling back to base model %s", BASE_MODEL_ID)
|
| 41 |
+
return BASE_MODEL_ID
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class MiniCPMVDetector:
|
| 45 |
+
"""Lazy-loading wrapper around MiniCPM-V 4.6 for film defect detection."""
|
| 46 |
+
|
| 47 |
+
def __init__(self, model_path: str | None = None) -> None:
|
| 48 |
+
self._model_path = model_path or _resolve_model_path()
|
| 49 |
+
self._model: Any = None
|
| 50 |
+
self._processor: Any = None
|
| 51 |
+
self._dtype: Any = None
|
| 52 |
+
self._device: str = "cpu"
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def model_path(self) -> str:
|
| 56 |
+
return self._model_path
|
| 57 |
+
|
| 58 |
+
def load(self) -> None:
|
| 59 |
+
if self._model is not None:
|
| 60 |
+
return
|
| 61 |
+
import torch
|
| 62 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 63 |
+
|
| 64 |
+
logger.info("Loading MiniCPM-V 4.6 from %s", self._model_path)
|
| 65 |
+
self._processor = AutoProcessor.from_pretrained(
|
| 66 |
+
self._model_path, trust_remote_code=True
|
| 67 |
+
)
|
| 68 |
+
self._dtype = torch.bfloat16
|
| 69 |
+
self._model = AutoModelForImageTextToText.from_pretrained(
|
| 70 |
+
self._model_path,
|
| 71 |
+
torch_dtype=self._dtype,
|
| 72 |
+
device_map="auto",
|
| 73 |
+
trust_remote_code=True,
|
| 74 |
+
)
|
| 75 |
+
self._device = str(next(self._model.parameters()).device)
|
| 76 |
+
logger.info("Model loaded on %s", self._device)
|
| 77 |
+
|
| 78 |
+
def detect(self, image: Any) -> dict:
|
| 79 |
+
"""Run defect detection on a PIL image. Returns parsed JSON dict."""
|
| 80 |
+
import torch
|
| 81 |
+
|
| 82 |
+
if self._model is None:
|
| 83 |
+
self.load()
|
| 84 |
+
|
| 85 |
+
messages = [
|
| 86 |
+
{
|
| 87 |
+
"role": "user",
|
| 88 |
+
"content": [
|
| 89 |
+
{"type": "image", "image": image},
|
| 90 |
+
{"type": "text", "text": DETECTION_PROMPT},
|
| 91 |
+
],
|
| 92 |
+
}
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
inputs = self._processor.apply_chat_template(
|
| 96 |
+
messages,
|
| 97 |
+
tokenize=True,
|
| 98 |
+
add_generation_prompt=True,
|
| 99 |
+
return_dict=True,
|
| 100 |
+
return_tensors="pt",
|
| 101 |
+
downsample_mode=DOWNSAMPLE_MODE,
|
| 102 |
+
max_slice_nums=MAX_SLICE_NUMS,
|
| 103 |
+
).to(self._device)
|
| 104 |
+
|
| 105 |
+
with torch.inference_mode():
|
| 106 |
+
generated = self._model.generate(
|
| 107 |
+
**inputs,
|
| 108 |
+
downsample_mode=DOWNSAMPLE_MODE,
|
| 109 |
+
max_new_tokens=MAX_NEW_TOKENS,
|
| 110 |
+
do_sample=False,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
trimmed = [out[len(inp):] for inp, out in zip(inputs.input_ids, generated)]
|
| 114 |
+
text = self._processor.batch_decode(
|
| 115 |
+
trimmed,
|
| 116 |
+
skip_special_tokens=True,
|
| 117 |
+
clean_up_tokenization_spaces=False,
|
| 118 |
+
)[0]
|
| 119 |
+
|
| 120 |
+
return _parse_defect_json(text)
|
| 121 |
+
|
| 122 |
+
def close(self) -> None:
|
| 123 |
+
if self._model is not None:
|
| 124 |
+
del self._model
|
| 125 |
+
self._model = None
|
| 126 |
+
if self._processor is not None:
|
| 127 |
+
del self._processor
|
| 128 |
+
self._processor = None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _parse_defect_json(text: str) -> dict:
|
| 132 |
+
"""Extract and parse the first JSON object from model output."""
|
| 133 |
+
text = text.strip()
|
| 134 |
+
try:
|
| 135 |
+
return json.loads(text)
|
| 136 |
+
except json.JSONDecodeError:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
+
match = re.search(r"\{[\s\S]*\}", text)
|
| 140 |
+
if not match:
|
| 141 |
+
logger.warning("No JSON found in model output: %r", text[:200])
|
| 142 |
+
return {"defects": [], "_raw": text, "_parse_error": "no_json_object"}
|
| 143 |
+
try:
|
| 144 |
+
return json.loads(match.group(0))
|
| 145 |
+
except json.JSONDecodeError as exc:
|
| 146 |
+
logger.warning("JSON parse error: %s; raw: %r", exc, text[:200])
|
| 147 |
+
return {"defects": [], "_raw": text, "_parse_error": str(exc)}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
_default_detector: MiniCPMVDetector | None = None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def get_detector() -> MiniCPMVDetector:
|
| 154 |
+
global _default_detector
|
| 155 |
+
if _default_detector is None:
|
| 156 |
+
_default_detector = MiniCPMVDetector()
|
| 157 |
+
return _default_detector
|
pipeline/.gitkeep
ADDED
|
File without changes
|
pipeline/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Pipeline package. Orchestrates vision + reasoning stages."""
|
pipeline/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (247 Bytes). View file
|
|
|
pipeline/__pycache__/diagnoser.cpython-313.pyc
ADDED
|
Binary file (2.21 kB). View file
|
|
|
pipeline/__pycache__/extractor.cpython-313.pyc
ADDED
|
Binary file (853 Bytes). View file
|
|
|
pipeline/__pycache__/pipeline.cpython-313.pyc
ADDED
|
Binary file (2.07 kB). View file
|
|
|
pipeline/diagnoser.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Diagnoser. Takes defect JSON and user metadata, returns diagnosis and fixes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from models.reasoning.nemotron_wrapper import get_reasoner
|
| 10 |
+
from models.reasoning.prompts import SYSTEM_PROMPT, build_messages
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def diagnose(
|
| 16 |
+
defect_result: dict,
|
| 17 |
+
film_type: str,
|
| 18 |
+
film_age_years: int,
|
| 19 |
+
storage: str,
|
| 20 |
+
scan_resolution_dpi: int,
|
| 21 |
+
) -> dict:
|
| 22 |
+
"""Run Nemotron reasoning over a defect result + user metadata.
|
| 23 |
+
|
| 24 |
+
Returns a dict with the raw text response and timing metadata.
|
| 25 |
+
"""
|
| 26 |
+
started = time.perf_counter()
|
| 27 |
+
reasoner = get_reasoner()
|
| 28 |
+
|
| 29 |
+
label_counts = defect_result.get("label_counts", {}) or {}
|
| 30 |
+
total = defect_result.get("defect_count", 0) or sum(label_counts.values())
|
| 31 |
+
|
| 32 |
+
messages = build_messages(
|
| 33 |
+
film_type=film_type,
|
| 34 |
+
film_age_years=film_age_years,
|
| 35 |
+
storage=storage,
|
| 36 |
+
scan_resolution_dpi=scan_resolution_dpi,
|
| 37 |
+
defect_summary=label_counts,
|
| 38 |
+
total_defects=total,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
logger.info(
|
| 42 |
+
"Running Nemotron diagnosis (film=%s, age=%d, storage=%s, total_defects=%d)",
|
| 43 |
+
film_type, film_age_years, storage, total,
|
| 44 |
+
)
|
| 45 |
+
text = reasoner.generate(prompt=messages, system=SYSTEM_PROMPT)
|
| 46 |
+
elapsed = time.perf_counter() - started
|
| 47 |
+
|
| 48 |
+
return {
|
| 49 |
+
"diagnosis_text": text,
|
| 50 |
+
"reasoning_seconds": round(elapsed, 3),
|
| 51 |
+
"model_path": reasoner.model_path,
|
| 52 |
+
"system_prompt": SYSTEM_PROMPT,
|
| 53 |
+
"input_defect_summary": {
|
| 54 |
+
"label_counts": label_counts,
|
| 55 |
+
"total": total,
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
__all__ = ["diagnose"]
|
pipeline/extractor.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Defect extractor. Takes a film scan and returns structured defect JSON.
|
| 2 |
+
|
| 3 |
+
This is a thin wrapper that re-exports `extract_defects` from the vision
|
| 4 |
+
inference module so the pipeline layer has a stable interface.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from models.vision.inference import extract_defects, extract_defects_from_path
|
| 12 |
+
|
| 13 |
+
__all__ = ["extract_defects", "extract_defects_from_path"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def extract(image: Any) -> dict:
|
| 17 |
+
"""Top-level entry point used by the pipeline orchestrator."""
|
| 18 |
+
return extract_defects(image)
|
pipeline/pipeline.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main pipeline. Orchestrates vision extraction and diagnostic reasoning."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from pipeline.diagnoser import diagnose
|
| 10 |
+
from pipeline.extractor import extract
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def run_diagnosis(
|
| 16 |
+
image: Any,
|
| 17 |
+
film_type: str = "Unknown 35mm",
|
| 18 |
+
film_age_years: int = 1,
|
| 19 |
+
storage: str = "unknown",
|
| 20 |
+
scan_resolution_dpi: int = 4000,
|
| 21 |
+
) -> dict:
|
| 22 |
+
"""End-to-end: image -> defect JSON -> diagnosis + fixes.
|
| 23 |
+
|
| 24 |
+
Returns a single dict with both stages' outputs and timing info.
|
| 25 |
+
"""
|
| 26 |
+
started = time.perf_counter()
|
| 27 |
+
|
| 28 |
+
logger.info("Stage 1: defect extraction")
|
| 29 |
+
defect_result = extract(image)
|
| 30 |
+
|
| 31 |
+
logger.info(
|
| 32 |
+
"Stage 1 complete: %d defects (%s) in %.2fs",
|
| 33 |
+
defect_result.get("defect_count", 0),
|
| 34 |
+
defect_result.get("label_counts", {}),
|
| 35 |
+
defect_result.get("inference_seconds", 0.0),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
logger.info("Stage 2: Nemotron diagnosis")
|
| 39 |
+
diagnosis_result = diagnose(
|
| 40 |
+
defect_result,
|
| 41 |
+
film_type=film_type,
|
| 42 |
+
film_age_years=film_age_years,
|
| 43 |
+
storage=storage,
|
| 44 |
+
scan_resolution_dpi=scan_resolution_dpi,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
total_elapsed = time.perf_counter() - started
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
"film_metadata": {
|
| 51 |
+
"film_type": film_type,
|
| 52 |
+
"film_age_years": film_age_years,
|
| 53 |
+
"storage": storage,
|
| 54 |
+
"scan_resolution_dpi": scan_resolution_dpi,
|
| 55 |
+
},
|
| 56 |
+
"defects": defect_result,
|
| 57 |
+
"diagnosis": diagnosis_result,
|
| 58 |
+
"total_seconds": round(total_elapsed, 3),
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
__all__ = ["run_diagnosis"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Halide Space Dependencies
|
| 2 |
+
# Pinning to versions known to work with MiniCPM-V 4.6
|
| 3 |
+
|
| 4 |
+
# Core
|
| 5 |
+
gradio==6.16.0
|
| 6 |
+
torch==2.7.0
|
| 7 |
+
torchvision==0.22.0
|
| 8 |
+
|
| 9 |
+
# HuggingFace stack
|
| 10 |
+
transformers==5.7.0
|
| 11 |
+
accelerate>=1.0.0
|
| 12 |
+
huggingface_hub>=0.20.0
|
| 13 |
+
pillow>=10.0.0
|
| 14 |
+
|
| 15 |
+
# Nemotron reasoning uses default transformers tokenizer
|
| 16 |
+
numpy>=1.24.0
|
storage/.gitkeep
ADDED
|
File without changes
|
storage/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Storage package. SQLite database and inference cache."""
|
storage/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (242 Bytes). View file
|
|
|
storage/__pycache__/cache.cpython-313.pyc
ADDED
|
Binary file (4.81 kB). View file
|
|
|
storage/__pycache__/database.cpython-313.pyc
ADDED
|
Binary file (7.61 kB). View file
|
|
|
storage/cache.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Caching. In-process LRU cache for diagnosis results keyed by image hash.
|
| 2 |
+
|
| 3 |
+
For privacy, we hash the image bytes; the image itself is never persisted
|
| 4 |
+
in the cache. Identical scans produce identical hashes, giving us a simple
|
| 5 |
+
content-addressed cache.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import time
|
| 14 |
+
from collections import OrderedDict
|
| 15 |
+
from threading import Lock
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class DiagnosisCache:
|
| 22 |
+
"""Thread-safe LRU cache for diagnosis results."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, max_size: int = 64, ttl_seconds: int = 3600) -> None:
|
| 25 |
+
self._max_size = max_size
|
| 26 |
+
self._ttl = ttl_seconds
|
| 27 |
+
self._store: OrderedDict[str, tuple[float, dict]] = OrderedDict()
|
| 28 |
+
self._lock = Lock()
|
| 29 |
+
self._hits = 0
|
| 30 |
+
self._misses = 0
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def hash_image(image_bytes: bytes) -> str:
|
| 34 |
+
return hashlib.sha256(image_bytes).hexdigest()
|
| 35 |
+
|
| 36 |
+
def get(self, image_bytes: bytes) -> dict | None:
|
| 37 |
+
key = self.hash_image(image_bytes)
|
| 38 |
+
now = time.time()
|
| 39 |
+
with self._lock:
|
| 40 |
+
entry = self._store.get(key)
|
| 41 |
+
if entry is None:
|
| 42 |
+
self._misses += 1
|
| 43 |
+
return None
|
| 44 |
+
ts, value = entry
|
| 45 |
+
if now - ts > self._ttl:
|
| 46 |
+
del self._store[key]
|
| 47 |
+
self._misses += 1
|
| 48 |
+
return None
|
| 49 |
+
self._store.move_to_end(key)
|
| 50 |
+
self._hits += 1
|
| 51 |
+
logger.info("Cache hit for %s", key[:12])
|
| 52 |
+
return value
|
| 53 |
+
|
| 54 |
+
def put(self, image_bytes: bytes, value: dict) -> None:
|
| 55 |
+
key = self.hash_image(image_bytes)
|
| 56 |
+
now = time.time()
|
| 57 |
+
with self._lock:
|
| 58 |
+
self._store[key] = (now, value)
|
| 59 |
+
self._store.move_to_end(key)
|
| 60 |
+
while len(self._store) > self._max_size:
|
| 61 |
+
self._store.popitem(last=False)
|
| 62 |
+
|
| 63 |
+
def stats(self) -> dict:
|
| 64 |
+
with self._lock:
|
| 65 |
+
return {
|
| 66 |
+
"size": len(self._store),
|
| 67 |
+
"max_size": self._max_size,
|
| 68 |
+
"hits": self._hits,
|
| 69 |
+
"misses": self._misses,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
def clear(self) -> None:
|
| 73 |
+
with self._lock:
|
| 74 |
+
self._store.clear()
|
| 75 |
+
self._hits = 0
|
| 76 |
+
self._misses = 0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
_default_cache: DiagnosisCache | None = None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def get_cache() -> DiagnosisCache:
|
| 83 |
+
global _default_cache
|
| 84 |
+
if _default_cache is None:
|
| 85 |
+
_default_cache = DiagnosisCache()
|
| 86 |
+
return _default_cache
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
__all__ = ["DiagnosisCache", "get_cache"]
|
storage/database.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite database. Stores diagnostic history and user sessions.
|
| 2 |
+
|
| 3 |
+
Schema:
|
| 4 |
+
sessions(id, started_at, film_type, film_age_years, storage, scan_dpi)
|
| 5 |
+
diagnoses(id, session_id, created_at, defect_count, label_counts_json,
|
| 6 |
+
diagnosis_text, vision_seconds, reasoning_seconds, total_seconds,
|
| 7 |
+
vision_model, reasoning_model, raw_json)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import sqlite3
|
| 15 |
+
import time
|
| 16 |
+
import uuid
|
| 17 |
+
from contextlib import contextmanager
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Iterator
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 24 |
+
DEFAULT_DB_PATH = REPO_ROOT / "storage" / "halide.db"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_db_path() -> Path:
|
| 28 |
+
import os
|
| 29 |
+
custom = os.getenv("HALIDE_DB_PATH")
|
| 30 |
+
if custom:
|
| 31 |
+
return Path(custom)
|
| 32 |
+
DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
return DEFAULT_DB_PATH
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
SCHEMA = """
|
| 37 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 38 |
+
id TEXT PRIMARY KEY,
|
| 39 |
+
started_at REAL NOT NULL,
|
| 40 |
+
film_type TEXT NOT NULL,
|
| 41 |
+
film_age_years INTEGER NOT NULL,
|
| 42 |
+
storage TEXT NOT NULL,
|
| 43 |
+
scan_dpi INTEGER NOT NULL
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
CREATE TABLE IF NOT EXISTS diagnoses (
|
| 47 |
+
id TEXT PRIMARY KEY,
|
| 48 |
+
session_id TEXT NOT NULL,
|
| 49 |
+
created_at REAL NOT NULL,
|
| 50 |
+
defect_count INTEGER NOT NULL,
|
| 51 |
+
label_counts_json TEXT NOT NULL,
|
| 52 |
+
diagnosis_text TEXT NOT NULL,
|
| 53 |
+
vision_seconds REAL NOT NULL,
|
| 54 |
+
reasoning_seconds REAL NOT NULL,
|
| 55 |
+
total_seconds REAL NOT NULL,
|
| 56 |
+
vision_model TEXT NOT NULL,
|
| 57 |
+
reasoning_model TEXT NOT NULL,
|
| 58 |
+
raw_json TEXT NOT NULL,
|
| 59 |
+
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
CREATE INDEX IF NOT EXISTS idx_diagnoses_session ON diagnoses(session_id);
|
| 63 |
+
CREATE INDEX IF NOT EXISTS idx_diagnoses_created ON diagnoses(created_at);
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@contextmanager
|
| 68 |
+
def connect() -> Iterator[sqlite3.Connection]:
|
| 69 |
+
db_path = get_db_path()
|
| 70 |
+
conn = sqlite3.connect(str(db_path))
|
| 71 |
+
conn.row_factory = sqlite3.Row
|
| 72 |
+
try:
|
| 73 |
+
yield conn
|
| 74 |
+
conn.commit()
|
| 75 |
+
finally:
|
| 76 |
+
conn.close()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def init_db() -> None:
|
| 80 |
+
with connect() as conn:
|
| 81 |
+
conn.executescript(SCHEMA)
|
| 82 |
+
logger.info("DB initialized at %s", get_db_path())
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def record_diagnosis(result: dict) -> str:
|
| 86 |
+
"""Persist a full pipeline result. Returns the diagnosis id."""
|
| 87 |
+
diagnosis_id = str(uuid.uuid4())
|
| 88 |
+
session_id = str(uuid.uuid4())
|
| 89 |
+
now = time.time()
|
| 90 |
+
|
| 91 |
+
meta = result.get("film_metadata", {}) or {}
|
| 92 |
+
defects = result.get("defects", {}) or {}
|
| 93 |
+
diagnosis = result.get("diagnosis", {}) or {}
|
| 94 |
+
|
| 95 |
+
with connect() as conn:
|
| 96 |
+
conn.execute(
|
| 97 |
+
"""
|
| 98 |
+
INSERT INTO sessions (id, started_at, film_type, film_age_years,
|
| 99 |
+
storage, scan_dpi)
|
| 100 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 101 |
+
""",
|
| 102 |
+
(
|
| 103 |
+
session_id,
|
| 104 |
+
now,
|
| 105 |
+
meta.get("film_type", "Unknown"),
|
| 106 |
+
int(meta.get("film_age_years", 0) or 0),
|
| 107 |
+
meta.get("storage", "unknown"),
|
| 108 |
+
int(meta.get("scan_resolution_dpi", 0) or 0),
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
conn.execute(
|
| 112 |
+
"""
|
| 113 |
+
INSERT INTO diagnoses (id, session_id, created_at, defect_count,
|
| 114 |
+
label_counts_json, diagnosis_text,
|
| 115 |
+
vision_seconds, reasoning_seconds,
|
| 116 |
+
total_seconds, vision_model, reasoning_model,
|
| 117 |
+
raw_json)
|
| 118 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 119 |
+
""",
|
| 120 |
+
(
|
| 121 |
+
diagnosis_id,
|
| 122 |
+
session_id,
|
| 123 |
+
now,
|
| 124 |
+
int(defects.get("defect_count", 0) or 0),
|
| 125 |
+
json.dumps(defects.get("label_counts", {}) or {}),
|
| 126 |
+
diagnosis.get("diagnosis_text", ""),
|
| 127 |
+
float(defects.get("inference_seconds", 0.0) or 0.0),
|
| 128 |
+
float(diagnosis.get("reasoning_seconds", 0.0) or 0.0),
|
| 129 |
+
float(result.get("total_seconds", 0.0) or 0.0),
|
| 130 |
+
defects.get("model_path", ""),
|
| 131 |
+
diagnosis.get("model_path", ""),
|
| 132 |
+
json.dumps(result),
|
| 133 |
+
),
|
| 134 |
+
)
|
| 135 |
+
logger.info("Recorded diagnosis %s (session %s)", diagnosis_id, session_id)
|
| 136 |
+
return diagnosis_id
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def list_recent(limit: int = 20) -> list[dict]:
|
| 140 |
+
with connect() as conn:
|
| 141 |
+
rows = conn.execute(
|
| 142 |
+
"""
|
| 143 |
+
SELECT d.id, d.created_at, s.film_type, s.film_age_years,
|
| 144 |
+
s.storage, s.scan_dpi, d.defect_count, d.label_counts_json,
|
| 145 |
+
d.diagnosis_text, d.total_seconds
|
| 146 |
+
FROM diagnoses d
|
| 147 |
+
JOIN sessions s ON s.id = d.session_id
|
| 148 |
+
ORDER BY d.created_at DESC
|
| 149 |
+
LIMIT ?
|
| 150 |
+
""",
|
| 151 |
+
(limit,),
|
| 152 |
+
).fetchall()
|
| 153 |
+
out: list[dict] = []
|
| 154 |
+
for r in rows:
|
| 155 |
+
out.append(
|
| 156 |
+
{
|
| 157 |
+
"id": r["id"],
|
| 158 |
+
"created_at": r["created_at"],
|
| 159 |
+
"film_type": r["film_type"],
|
| 160 |
+
"film_age_years": r["film_age_years"],
|
| 161 |
+
"storage": r["storage"],
|
| 162 |
+
"scan_dpi": r["scan_dpi"],
|
| 163 |
+
"defect_count": r["defect_count"],
|
| 164 |
+
"label_counts": json.loads(r["label_counts_json"]),
|
| 165 |
+
"diagnosis_text": r["diagnosis_text"],
|
| 166 |
+
"total_seconds": r["total_seconds"],
|
| 167 |
+
}
|
| 168 |
+
)
|
| 169 |
+
return out
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
__all__ = [
|
| 173 |
+
"init_db",
|
| 174 |
+
"record_diagnosis",
|
| 175 |
+
"list_recent",
|
| 176 |
+
"get_db_path",
|
| 177 |
+
]
|
storage/halide.db
ADDED
|
Binary file (28.7 kB). View file
|
|
|
ui/.gitkeep
ADDED
|
File without changes
|
ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""UI package. Gradio app, theme, and components."""
|
ui/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (230 Bytes). View file
|
|
|
ui/__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (9.99 kB). View file
|
|
|
ui/__pycache__/components.cpython-313.pyc
ADDED
|
Binary file (5.84 kB). View file
|
|
|
ui/__pycache__/theme.cpython-313.pyc
ADDED
|
Binary file (6.62 kB). View file
|
|
|
ui/app.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio app. Main UI definition and layout."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import html
|
| 6 |
+
import io
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import gradio as gr
|
| 11 |
+
|
| 12 |
+
from pipeline.pipeline import run_diagnosis
|
| 13 |
+
from storage.cache import get_cache
|
| 14 |
+
from storage.database import init_db, list_recent, record_diagnosis
|
| 15 |
+
from ui.components import (
|
| 16 |
+
HEADER_HTML,
|
| 17 |
+
THEME_CSS,
|
| 18 |
+
defect_pills_html,
|
| 19 |
+
diagnosis_html,
|
| 20 |
+
render_history,
|
| 21 |
+
stats_html,
|
| 22 |
+
)
|
| 23 |
+
from ui.theme import build_theme
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
logging.basicConfig(level=logging.INFO)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
DEFAULT_FILM_TYPES = [
|
| 30 |
+
"Kodak Portra 400 (35mm)",
|
| 31 |
+
"Kodak Tri-X 400 (35mm)",
|
| 32 |
+
"Kodak Ektar 100 (35mm)",
|
| 33 |
+
"Ilford HP5 Plus (35mm)",
|
| 34 |
+
"Ilford Delta 100 (35mm)",
|
| 35 |
+
"Ilford FP4 Plus (120)",
|
| 36 |
+
"CineStill 800T (35mm)",
|
| 37 |
+
"Fujifilm Pro 400H (35mm)",
|
| 38 |
+
"Fomapan 400 (35mm)",
|
| 39 |
+
"Other / Unknown",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
STORAGE_OPTIONS = [
|
| 43 |
+
"fridge, sealed",
|
| 44 |
+
"freezer, sealed",
|
| 45 |
+
"room temp, sealed",
|
| 46 |
+
"room temp, loose",
|
| 47 |
+
"shoe box, attic",
|
| 48 |
+
"shoe box, basement",
|
| 49 |
+
"unknown",
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
RESOLUTION_OPTIONS = [2000, 3000, 4000, 5000, 6000, 8000]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _image_to_bytes(pil_image: Any) -> bytes:
|
| 56 |
+
buf = io.BytesIO()
|
| 57 |
+
pil_image.save(buf, format="PNG")
|
| 58 |
+
return buf.getvalue()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def run_pipeline(
|
| 62 |
+
image: Any,
|
| 63 |
+
film_type: str,
|
| 64 |
+
film_age_years: int,
|
| 65 |
+
storage: str,
|
| 66 |
+
scan_dpi: int,
|
| 67 |
+
progress: gr.Progress = gr.Progress(),
|
| 68 |
+
) -> tuple[str, str, str, str]:
|
| 69 |
+
"""Gradio handler for the diagnose button."""
|
| 70 |
+
if image is None:
|
| 71 |
+
empty = '<p style="color: var(--halide-crimson);">No image provided.</p>'
|
| 72 |
+
return empty, empty, empty, render_history(list_recent(limit=10))
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
progress(0.0, "Hashing image for cache lookup...")
|
| 76 |
+
cache = get_cache()
|
| 77 |
+
image_bytes = _image_to_bytes(image)
|
| 78 |
+
cached = cache.get(image_bytes)
|
| 79 |
+
if cached is not None:
|
| 80 |
+
logger.info("Returning cached diagnosis")
|
| 81 |
+
result = cached
|
| 82 |
+
else:
|
| 83 |
+
progress(0.1, "Stage 1/2: running vision defect extraction...")
|
| 84 |
+
result = run_diagnosis(
|
| 85 |
+
image=image,
|
| 86 |
+
film_type=film_type or "Unknown 35mm",
|
| 87 |
+
film_age_years=int(film_age_years or 0),
|
| 88 |
+
storage=storage or "unknown",
|
| 89 |
+
scan_resolution_dpi=int(scan_dpi or 4000),
|
| 90 |
+
)
|
| 91 |
+
progress(0.85, "Stage 2/2: persisting diagnosis...")
|
| 92 |
+
try:
|
| 93 |
+
record_diagnosis(result)
|
| 94 |
+
except Exception as exc: # pragma: no cover
|
| 95 |
+
logger.warning("Failed to record diagnosis: %s", exc)
|
| 96 |
+
cache.put(image_bytes, result)
|
| 97 |
+
|
| 98 |
+
progress(1.0, "Done.")
|
| 99 |
+
|
| 100 |
+
counts = result.get("defects", {}).get("label_counts", {}) or {}
|
| 101 |
+
stats = stats_html(result)
|
| 102 |
+
pills = defect_pills_html(counts)
|
| 103 |
+
diag = diagnosis_html(result.get("diagnosis", {}).get("diagnosis_text", ""))
|
| 104 |
+
history = render_history(list_recent(limit=10))
|
| 105 |
+
return stats, pills, diag, history
|
| 106 |
+
except Exception as exc: # pragma: no cover
|
| 107 |
+
logger.exception("Pipeline failed")
|
| 108 |
+
err = (
|
| 109 |
+
'<div class="halide-card" style="border-color: var(--halide-crimson);">'
|
| 110 |
+
f'<div class="halide-section-title" style="color: var(--halide-red);">'
|
| 111 |
+
f"Pipeline error</div>"
|
| 112 |
+
f"<pre style=\"color: var(--halide-parchment); white-space: pre-wrap;\">"
|
| 113 |
+
f"{html.escape(str(exc))}</pre></div>"
|
| 114 |
+
)
|
| 115 |
+
return err, "", "", render_history(list_recent(limit=10))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def refresh_history() -> str:
|
| 119 |
+
return render_history(list_recent(limit=10))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def build_app() -> gr.Blocks:
|
| 123 |
+
init_db()
|
| 124 |
+
theme = build_theme()
|
| 125 |
+
|
| 126 |
+
with gr.Blocks(title="Project Halide") as app:
|
| 127 |
+
gr.HTML(HEADER_HTML)
|
| 128 |
+
|
| 129 |
+
with gr.Row():
|
| 130 |
+
with gr.Column(scale=1):
|
| 131 |
+
with gr.Group(elem_classes="halide-card"):
|
| 132 |
+
gr.Markdown('<div class="halide-section-title">Scan upload</div>')
|
| 133 |
+
image_input = gr.Image(
|
| 134 |
+
label="Film scan",
|
| 135 |
+
type="pil",
|
| 136 |
+
height=380,
|
| 137 |
+
sources=["upload", "clipboard"],
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
with gr.Group(elem_classes="halide-card"):
|
| 141 |
+
gr.Markdown('<div class="halide-section-title">Film metadata</div>')
|
| 142 |
+
film_type = gr.Dropdown(
|
| 143 |
+
choices=DEFAULT_FILM_TYPES,
|
| 144 |
+
value=DEFAULT_FILM_TYPES[0],
|
| 145 |
+
label="Film stock",
|
| 146 |
+
)
|
| 147 |
+
with gr.Row():
|
| 148 |
+
film_age = gr.Slider(
|
| 149 |
+
minimum=0,
|
| 150 |
+
maximum=80,
|
| 151 |
+
step=1,
|
| 152 |
+
value=2,
|
| 153 |
+
label="Age (years)",
|
| 154 |
+
)
|
| 155 |
+
scan_dpi = gr.Dropdown(
|
| 156 |
+
choices=RESOLUTION_OPTIONS,
|
| 157 |
+
value=4000,
|
| 158 |
+
label="Scan resolution (dpi)",
|
| 159 |
+
)
|
| 160 |
+
storage = gr.Radio(
|
| 161 |
+
choices=STORAGE_OPTIONS,
|
| 162 |
+
value=STORAGE_OPTIONS[0],
|
| 163 |
+
label="Storage condition",
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
run_btn = gr.Button("Diagnose scan", variant="primary", size="lg")
|
| 167 |
+
|
| 168 |
+
with gr.Column(scale=2):
|
| 169 |
+
with gr.Group(elem_classes="halide-card"):
|
| 170 |
+
gr.Markdown('<div class="halide-section-title">Defect summary</div>')
|
| 171 |
+
defect_summary = gr.HTML(
|
| 172 |
+
value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
with gr.Group(elem_classes="halide-card"):
|
| 176 |
+
gr.Markdown('<div class="halide-section-title">Diagnosis & fixes</div>')
|
| 177 |
+
diagnosis_output = gr.HTML(
|
| 178 |
+
value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
with gr.Group(elem_classes="halide-card"):
|
| 182 |
+
gr.Markdown('<div class="halide-section-title">Session stats</div>')
|
| 183 |
+
stats_output = gr.HTML(
|
| 184 |
+
value='<p style="color: var(--halide-slate);">Awaiting scan.</p>'
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
with gr.Column(scale=1):
|
| 188 |
+
with gr.Group(elem_classes="halide-card"):
|
| 189 |
+
gr.Markdown('<div class="halide-section-title">Recent diagnoses</div>')
|
| 190 |
+
history_output = gr.HTML(value=render_history(list_recent(limit=10)))
|
| 191 |
+
refresh_btn = gr.Button("Refresh history", size="sm")
|
| 192 |
+
|
| 193 |
+
gr.HTML(
|
| 194 |
+
"<footer>Project Halide. Edge-native, no cloud APIs. "
|
| 195 |
+
"Vision: MiniCPM-V 4.6 (1.3B). Reasoning: Nemotron-Mini-4B-Instruct (few-shot).</footer>"
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
run_btn.click(
|
| 199 |
+
fn=run_pipeline,
|
| 200 |
+
inputs=[image_input, film_type, film_age, storage, scan_dpi],
|
| 201 |
+
outputs=[stats_output, defect_summary, diagnosis_output, history_output],
|
| 202 |
+
)
|
| 203 |
+
refresh_btn.click(fn=refresh_history, outputs=[history_output])
|
| 204 |
+
|
| 205 |
+
return app
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def main() -> None:
|
| 209 |
+
app = build_app()
|
| 210 |
+
app.queue(max_size=8).launch(
|
| 211 |
+
server_name="0.0.0.0",
|
| 212 |
+
server_port=7860,
|
| 213 |
+
theme=build_theme(),
|
| 214 |
+
css=THEME_CSS,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
if __name__ == "__main__":
|
| 219 |
+
main()
|
ui/components.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""UI components. Defect list rendering and shared visual helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Iterable
|
| 6 |
+
|
| 7 |
+
from ui.theme import THEME_CSS
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
HEADER_HTML = """
|
| 11 |
+
<div id="halide-header">
|
| 12 |
+
<h1>Project Halide</h1>
|
| 13 |
+
<p>Edge-native diagnostic engine for analog film scans</p>
|
| 14 |
+
</div>
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def defect_pills_html(label_counts: dict[str, int]) -> str:
|
| 19 |
+
"""Render defect counts as colored pills."""
|
| 20 |
+
if not label_counts:
|
| 21 |
+
return '<p style="color: var(--halide-slate);">No defects detected.</p>'
|
| 22 |
+
pills: list[str] = []
|
| 23 |
+
for label, count in sorted(label_counts.items(), key=lambda kv: -kv[1]):
|
| 24 |
+
pills.append(
|
| 25 |
+
f'<span class="halide-defect-pill {label}">{label}: {count}</span>'
|
| 26 |
+
)
|
| 27 |
+
return '<div>' + "".join(pills) + "</div>"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def stats_html(result: dict) -> str:
|
| 31 |
+
"""Render a stats card with defect counts and timing."""
|
| 32 |
+
defects = result.get("defects", {}) or {}
|
| 33 |
+
diagnosis = result.get("diagnosis", {}) or {}
|
| 34 |
+
total = result.get("total_seconds", 0.0) or 0.0
|
| 35 |
+
vision_s = defects.get("inference_seconds", 0.0) or 0.0
|
| 36 |
+
reasoning_s = diagnosis.get("reasoning_seconds", 0.0) or 0.0
|
| 37 |
+
|
| 38 |
+
rows: list[str] = []
|
| 39 |
+
rows.append(_stat_row("Total defects", str(defects.get("defect_count", 0))))
|
| 40 |
+
rows.append(_stat_row("Dropped (invalid)", str(defects.get("dropped_count", 0))))
|
| 41 |
+
rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
|
| 42 |
+
rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
|
| 43 |
+
rows.append(_stat_row("Total", f"{total:.2f}s"))
|
| 44 |
+
rows.append(_stat_row("Vision model", _truncate(defects.get("model_path", ""), 50)))
|
| 45 |
+
rows.append(_stat_row("Reasoning model", _truncate(diagnosis.get("model_path", ""), 50)))
|
| 46 |
+
return f'<div class="halide-card">{"".join(rows)}</div>'
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _stat_row(label: str, value: str) -> str:
|
| 50 |
+
return (
|
| 51 |
+
'<div class="halide-stat">'
|
| 52 |
+
f'<span class="halide-stat-label">{label}</span>'
|
| 53 |
+
f'<span>{value}</span>'
|
| 54 |
+
"</div>"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _truncate(s: str, n: int) -> str:
|
| 59 |
+
if len(s) <= n:
|
| 60 |
+
return s
|
| 61 |
+
return "..." + s[-(n - 3):]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def diagnosis_html(text: str) -> str:
|
| 65 |
+
"""Wrap diagnosis text in the styled card."""
|
| 66 |
+
safe = (text or "(no diagnosis produced)").replace("\n", "<br>")
|
| 67 |
+
return f'<div class="halide-diagnosis">{safe}</div>'
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def history_row_html(entry: dict) -> str:
|
| 71 |
+
"""Render a single row in the recent-diagnoses sidebar."""
|
| 72 |
+
counts = entry.get("label_counts", {}) or {}
|
| 73 |
+
total = entry.get("defect_count", 0) or 0
|
| 74 |
+
film = entry.get("film_type", "Unknown")
|
| 75 |
+
age = entry.get("film_age_years", "?")
|
| 76 |
+
storage = entry.get("storage", "?")
|
| 77 |
+
ts = entry.get("created_at", 0)
|
| 78 |
+
seconds = entry.get("total_seconds", 0.0) or 0.0
|
| 79 |
+
return (
|
| 80 |
+
f'<div class="halide-card" style="margin-bottom: 0.6rem;">'
|
| 81 |
+
f'<div class="halide-section-title" style="font-size: 0.95rem;">'
|
| 82 |
+
f"{film} (age {age}y, {storage})</div>"
|
| 83 |
+
f"{defect_pills_html(counts)}"
|
| 84 |
+
f'<div style="color: var(--halide-slate); font-size: 0.8rem; margin-top: 0.4rem;">'
|
| 85 |
+
f"defects: {total} | {seconds:.2f}s | {ts:.0f}"
|
| 86 |
+
f"</div></div>"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def render_history(entries: Iterable[dict]) -> str:
|
| 91 |
+
items = "".join(history_row_html(e) for e in entries)
|
| 92 |
+
if not items:
|
| 93 |
+
return '<p style="color: var(--halide-slate);">No diagnoses yet.</p>'
|
| 94 |
+
return items
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
__all__ = [
|
| 98 |
+
"HEADER_HTML",
|
| 99 |
+
"THEME_CSS",
|
| 100 |
+
"defect_pills_html",
|
| 101 |
+
"stats_html",
|
| 102 |
+
"diagnosis_html",
|
| 103 |
+
"render_history",
|
| 104 |
+
]
|
ui/static/.gitkeep
ADDED
|
File without changes
|
ui/static/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/* Custom CSS for the autumn theme. Inlined into Gradio Blocks at runtime. */
|
ui/theme.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Autumn theme. Colors derived from the project logo (orange-to-red on black)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
AMBER = "#d97706"
|
| 8 |
+
AMBER_DEEP = "#b45309"
|
| 9 |
+
ORANGE = "#ea580c"
|
| 10 |
+
RED = "#dc2626"
|
| 11 |
+
CRIMSON = "#991b1b"
|
| 12 |
+
EMBER = "#f59e0b"
|
| 13 |
+
|
| 14 |
+
INK = "#0c0a09"
|
| 15 |
+
INK_SOFT = "#1c1917"
|
| 16 |
+
PARCHMENT = "#fef3c7"
|
| 17 |
+
PARCHMENT_DEEP = "#fde68a"
|
| 18 |
+
SLATE = "#44403c"
|
| 19 |
+
|
| 20 |
+
THEME_CSS = f"""
|
| 21 |
+
:root {{
|
| 22 |
+
--halide-amber: {AMBER};
|
| 23 |
+
--halide-amber-deep: {AMBER_DEEP};
|
| 24 |
+
--halide-orange: {ORANGE};
|
| 25 |
+
--halide-red: {RED};
|
| 26 |
+
--halide-crimson: {CRIMSON};
|
| 27 |
+
--halide-ember: {EMBER};
|
| 28 |
+
--halide-ink: {INK};
|
| 29 |
+
--halide-ink-soft: {INK_SOFT};
|
| 30 |
+
--halide-parchment: {PARCHMENT};
|
| 31 |
+
--halide-parchment-deep: {PARCHMENT_DEEP};
|
| 32 |
+
--halide-slate: {SLATE};
|
| 33 |
+
}}
|
| 34 |
+
|
| 35 |
+
body, .gradio-container {{
|
| 36 |
+
background: linear-gradient(180deg, #0c0a09 0%, #1c1917 50%, #0c0a09 100%);
|
| 37 |
+
color: var(--halide-parchment);
|
| 38 |
+
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
| 39 |
+
}}
|
| 40 |
+
|
| 41 |
+
#halide-header {{
|
| 42 |
+
background: linear-gradient(90deg, var(--halide-crimson) 0%, var(--halide-orange) 50%, var(--halide-amber) 100%);
|
| 43 |
+
padding: 1.4rem 2rem;
|
| 44 |
+
border-radius: 0 0 18px 18px;
|
| 45 |
+
margin-bottom: 1.5rem;
|
| 46 |
+
box-shadow: 0 8px 32px rgba(217, 119, 6, 0.25);
|
| 47 |
+
border-bottom: 1px solid var(--halide-amber);
|
| 48 |
+
}}
|
| 49 |
+
|
| 50 |
+
#halide-header h1 {{
|
| 51 |
+
color: var(--halide-parchment);
|
| 52 |
+
font-size: 2.4rem;
|
| 53 |
+
margin: 0;
|
| 54 |
+
letter-spacing: 0.02em;
|
| 55 |
+
text-shadow: 0 2px 4px rgba(0,0,0,0.4);
|
| 56 |
+
}}
|
| 57 |
+
|
| 58 |
+
#halide-header p {{
|
| 59 |
+
color: var(--halide-parchment-deep);
|
| 60 |
+
margin: 0.4rem 0 0 0;
|
| 61 |
+
font-size: 1.05rem;
|
| 62 |
+
font-style: italic;
|
| 63 |
+
}}
|
| 64 |
+
|
| 65 |
+
.halide-card {{
|
| 66 |
+
background: rgba(28, 25, 23, 0.85);
|
| 67 |
+
border: 1px solid var(--halide-amber-deep);
|
| 68 |
+
border-radius: 12px;
|
| 69 |
+
padding: 1.2rem;
|
| 70 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
| 71 |
+
}}
|
| 72 |
+
|
| 73 |
+
.halide-section-title {{
|
| 74 |
+
color: var(--halide-amber);
|
| 75 |
+
font-size: 1.15rem;
|
| 76 |
+
font-weight: 600;
|
| 77 |
+
letter-spacing: 0.05em;
|
| 78 |
+
text-transform: uppercase;
|
| 79 |
+
margin-bottom: 0.6rem;
|
| 80 |
+
border-bottom: 1px solid var(--halide-amber-deep);
|
| 81 |
+
padding-bottom: 0.3rem;
|
| 82 |
+
}}
|
| 83 |
+
|
| 84 |
+
.halide-stat {{
|
| 85 |
+
display: flex;
|
| 86 |
+
justify-content: space-between;
|
| 87 |
+
padding: 0.4rem 0;
|
| 88 |
+
border-bottom: 1px dotted var(--halide-slate);
|
| 89 |
+
color: var(--halide-parchment);
|
| 90 |
+
}}
|
| 91 |
+
|
| 92 |
+
.halide-stat-label {{
|
| 93 |
+
color: var(--halide-amber);
|
| 94 |
+
font-weight: 600;
|
| 95 |
+
}}
|
| 96 |
+
|
| 97 |
+
.halide-diagnosis {{
|
| 98 |
+
background: rgba(217, 119, 6, 0.08);
|
| 99 |
+
border-left: 4px solid var(--halide-amber);
|
| 100 |
+
padding: 1rem 1.2rem;
|
| 101 |
+
border-radius: 6px;
|
| 102 |
+
white-space: pre-wrap;
|
| 103 |
+
font-size: 0.98rem;
|
| 104 |
+
line-height: 1.6;
|
| 105 |
+
color: var(--halide-parchment);
|
| 106 |
+
}}
|
| 107 |
+
|
| 108 |
+
.halide-defect-pill {{
|
| 109 |
+
display: inline-block;
|
| 110 |
+
background: var(--halide-amber);
|
| 111 |
+
color: var(--halide-ink);
|
| 112 |
+
padding: 0.2rem 0.7rem;
|
| 113 |
+
border-radius: 999px;
|
| 114 |
+
font-size: 0.85rem;
|
| 115 |
+
font-weight: 600;
|
| 116 |
+
margin: 0 0.3rem 0.3rem 0;
|
| 117 |
+
}}
|
| 118 |
+
|
| 119 |
+
.halide-defect-pill.dust {{ background: var(--halide-amber); color: var(--halide-ink); }}
|
| 120 |
+
.halide-defect-pill.dirt {{ background: var(--halide-orange); color: var(--halide-parchment); }}
|
| 121 |
+
.halide-defect-pill.scratch {{ background: var(--halide-red); color: var(--halide-parchment); }}
|
| 122 |
+
.halide-defect-pill.long_hair {{ background: var(--halide-crimson); color: var(--halide-parchment); }}
|
| 123 |
+
.halide-defect-pill.short_hair {{ background: var(--halide-ember); color: var(--halide-ink); }}
|
| 124 |
+
|
| 125 |
+
button.primary, .primary button {{
|
| 126 |
+
background: linear-gradient(135deg, var(--halide-orange), var(--halide-red)) !important;
|
| 127 |
+
color: var(--halide-parchment) !important;
|
| 128 |
+
border: 1px solid var(--halide-amber) !important;
|
| 129 |
+
font-weight: 600 !important;
|
| 130 |
+
letter-spacing: 0.02em !important;
|
| 131 |
+
box-shadow: 0 2px 12px rgba(234, 88, 12, 0.4) !important;
|
| 132 |
+
}}
|
| 133 |
+
|
| 134 |
+
button.primary:hover, .primary button:hover {{
|
| 135 |
+
background: linear-gradient(135deg, var(--halide-red), var(--halide-crimson)) !important;
|
| 136 |
+
}}
|
| 137 |
+
|
| 138 |
+
input, textarea, select {{
|
| 139 |
+
background: var(--halide-ink-soft) !important;
|
| 140 |
+
color: var(--halide-parchment) !important;
|
| 141 |
+
border: 1px solid var(--halide-amber-deep) !important;
|
| 142 |
+
}}
|
| 143 |
+
|
| 144 |
+
input:focus, textarea:focus, select:focus {{
|
| 145 |
+
border-color: var(--halide-amber) !important;
|
| 146 |
+
box-shadow: 0 0 0 2px rgba(217, 119, 6, 0.3) !important;
|
| 147 |
+
}}
|
| 148 |
+
|
| 149 |
+
label, .label, .gradio-radio label, .gradio-checkbox label {{
|
| 150 |
+
color: var(--halide-parchment-deep) !important;
|
| 151 |
+
font-weight: 500 !important;
|
| 152 |
+
}}
|
| 153 |
+
|
| 154 |
+
footer {{
|
| 155 |
+
color: var(--halide-slate) !important;
|
| 156 |
+
text-align: center;
|
| 157 |
+
padding: 1rem;
|
| 158 |
+
font-size: 0.85rem;
|
| 159 |
+
}}
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def build_theme() -> gr.Theme:
|
| 164 |
+
"""Build the autumn-themed Gradio theme."""
|
| 165 |
+
return gr.themes.Base(
|
| 166 |
+
primary_hue=gr.themes.Color(
|
| 167 |
+
c50="#fef3c7",
|
| 168 |
+
c100="#fde68a",
|
| 169 |
+
c200="#fcd34d",
|
| 170 |
+
c300="#fbbf24",
|
| 171 |
+
c400="#f59e0b",
|
| 172 |
+
c500=AMBER,
|
| 173 |
+
c600=AMBER_DEEP,
|
| 174 |
+
c700="#92400e",
|
| 175 |
+
c800="#78350f",
|
| 176 |
+
c900=CRIMSON,
|
| 177 |
+
c950="#7c2d12",
|
| 178 |
+
),
|
| 179 |
+
secondary_hue=gr.themes.Color(
|
| 180 |
+
c50="#fef3c7",
|
| 181 |
+
c100="#fde68a",
|
| 182 |
+
c200="#fcd34d",
|
| 183 |
+
c300="#fbbf24",
|
| 184 |
+
c400=EMBER,
|
| 185 |
+
c500=AMBER,
|
| 186 |
+
c600=ORANGE,
|
| 187 |
+
c700=RED,
|
| 188 |
+
c800=CRIMSON,
|
| 189 |
+
c900="#7c2d12",
|
| 190 |
+
c950="#431407",
|
| 191 |
+
),
|
| 192 |
+
neutral_hue=gr.themes.Color(
|
| 193 |
+
c50="#fafaf9",
|
| 194 |
+
c100="#f5f5f4",
|
| 195 |
+
c200="#e7e5e4",
|
| 196 |
+
c300="#d6d3d1",
|
| 197 |
+
c400=SLATE,
|
| 198 |
+
c500="#57534e",
|
| 199 |
+
c600="#44403c",
|
| 200 |
+
c700="#292524",
|
| 201 |
+
c800=INK_SOFT,
|
| 202 |
+
c900=INK,
|
| 203 |
+
c950="#0c0a09",
|
| 204 |
+
),
|
| 205 |
+
font=gr.themes.GoogleFont("Iowan Old Style"),
|
| 206 |
+
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
|
| 207 |
+
).set(
|
| 208 |
+
body_background_fill=INK,
|
| 209 |
+
body_background_fill_dark=INK,
|
| 210 |
+
body_text_color=PARCHMENT,
|
| 211 |
+
body_text_color_dark=PARCHMENT,
|
| 212 |
+
button_primary_background_fill=ORANGE,
|
| 213 |
+
button_primary_background_fill_dark=ORANGE,
|
| 214 |
+
button_primary_text_color=PARCHMENT,
|
| 215 |
+
button_primary_text_color_dark=PARCHMENT,
|
| 216 |
+
block_background_fill=INK_SOFT,
|
| 217 |
+
block_background_fill_dark=INK_SOFT,
|
| 218 |
+
block_border_color=AMBER_DEEP,
|
| 219 |
+
block_border_color_dark=AMBER_DEEP,
|
| 220 |
+
input_background_fill=INK,
|
| 221 |
+
input_background_fill_dark=INK,
|
| 222 |
+
input_border_color=AMBER_DEEP,
|
| 223 |
+
input_border_color_dark=AMBER_DEEP,
|
| 224 |
+
)
|