Lonelyguyse1 commited on
Commit
63c4f20
·
verified ·
1 Parent(s): 0299e6b

Deploy Project Halide Gradio Space

Browse files
README.md CHANGED
@@ -29,8 +29,10 @@ Project Halide is an edge-native diagnostic workbench for analog film scans by
29
  [Lonelyguyse1](https://huggingface.co/Lonelyguyse1).
30
 
31
  The runtime uses MiniCPM-V 4.6 for defect extraction and
32
- Nemotron-Mini-4B-Instruct for diagnostic reasoning. Model inference runs on the
33
- Space GPU runtime without cloud inference APIs.
 
 
34
 
35
  Fine-tuned vision model:
36
  <https://huggingface.co/Lonelyguyse1/halide-vision>
@@ -38,6 +40,14 @@ Fine-tuned vision model:
38
  Source repository:
39
  <https://github.com/Lonelyguyse1/Project-Halide>
40
 
41
- Demo video: pending final evaluation run.
42
 
43
- Social post: pending final evaluation run.
 
 
 
 
 
 
 
 
 
29
  [Lonelyguyse1](https://huggingface.co/Lonelyguyse1).
30
 
31
  The runtime uses MiniCPM-V 4.6 for defect extraction and
32
+ Nemotron-Mini-4B-Instruct for diagnostic reasoning. The vision pass combines
33
+ full-frame inspection with a tiled fallback for large scans where crack
34
+ networks are too small in the global image. Model inference runs on the Space
35
+ GPU runtime without cloud inference APIs.
36
 
37
  Fine-tuned vision model:
38
  <https://huggingface.co/Lonelyguyse1/halide-vision>
 
40
  Source repository:
41
  <https://github.com/Lonelyguyse1/Project-Halide>
42
 
43
+ Held-out validation summary:
44
 
45
+ - Four visibly damaged private negatives were detected with scratch and
46
+ emulsion-damage evidence.
47
+ - One near-clean private negative returned zero defects.
48
+ - A broad lifted crack network that failed full-frame inference was recovered by
49
+ the tiled fallback.
50
+
51
+ Demo video: pending publication.
52
+
53
+ Social post: pending publication.
config.py CHANGED
@@ -37,6 +37,13 @@ def env_int(name: str, default: int) -> int:
37
  return int(value)
38
 
39
 
 
 
 
 
 
 
 
40
  def env_path(name: str, default: Path) -> Path:
41
  value = os.getenv(name)
42
  return Path(value) if value else default
@@ -56,6 +63,12 @@ class VisionConfig:
56
  max_slice_nums: int
57
  max_new_tokens: int
58
  max_input_pixels: int
 
 
 
 
 
 
59
 
60
 
61
  @dataclass(frozen=True)
@@ -90,6 +103,12 @@ def get_vision_config() -> VisionConfig:
90
  max_slice_nums=env_int("HALIDE_MAX_SLICE_NUMS", 36),
91
  max_new_tokens=env_int("HALIDE_MAX_NEW_TOKENS", 2048),
92
  max_input_pixels=env_int("HALIDE_MAX_INPUT_PIXELS", 4_000_000),
 
 
 
 
 
 
93
  )
94
 
95
 
@@ -145,6 +164,7 @@ __all__ = [
145
  "STORAGE_DIR",
146
  "VisionConfig",
147
  "env_bool",
 
148
  "env_int",
149
  "env_path",
150
  "get_app_config",
 
37
  return int(value)
38
 
39
 
40
+ def env_float(name: str, default: float) -> float:
41
+ value = os.getenv(name)
42
+ if value is None or value.strip() == "":
43
+ return default
44
+ return float(value)
45
+
46
+
47
  def env_path(name: str, default: Path) -> Path:
48
  value = os.getenv(name)
49
  return Path(value) if value else default
 
63
  max_slice_nums: int
64
  max_new_tokens: int
65
  max_input_pixels: int
66
+ tile_fallback_enabled: bool
67
+ tile_fallback_min_defects: int
68
+ tile_min_side: int
69
+ tile_max_side: int
70
+ tile_overlap: float
71
+ tile_max_tiles: int
72
 
73
 
74
  @dataclass(frozen=True)
 
103
  max_slice_nums=env_int("HALIDE_MAX_SLICE_NUMS", 36),
104
  max_new_tokens=env_int("HALIDE_MAX_NEW_TOKENS", 2048),
105
  max_input_pixels=env_int("HALIDE_MAX_INPUT_PIXELS", 4_000_000),
106
+ tile_fallback_enabled=env_bool("HALIDE_ENABLE_TILE_FALLBACK", True),
107
+ tile_fallback_min_defects=env_int("HALIDE_TILE_FALLBACK_MIN_DEFECTS", 1),
108
+ tile_min_side=env_int("HALIDE_TILE_MIN_SIDE", 900),
109
+ tile_max_side=env_int("HALIDE_TILE_MAX_SIDE", 960),
110
+ tile_overlap=env_float("HALIDE_TILE_OVERLAP", 0.35),
111
+ tile_max_tiles=env_int("HALIDE_TILE_MAX_TILES", 9),
112
  )
113
 
114
 
 
164
  "STORAGE_DIR",
165
  "VisionConfig",
166
  "env_bool",
167
+ "env_float",
168
  "env_int",
169
  "env_path",
170
  "get_app_config",
data/preprocessing.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
 
5
  import hashlib
6
  import io
 
7
  from pathlib import Path
8
  from typing import Any, Iterable
9
 
@@ -46,6 +47,30 @@ def image_to_png_bytes(image: Image.Image) -> bytes:
46
  return buf.getvalue()
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def image_sha256(image: Image.Image | bytes) -> str:
50
  if isinstance(image, bytes):
51
  payload = image
@@ -106,6 +131,7 @@ __all__ = [
106
  "LABEL_STYLE",
107
  "draw_defects",
108
  "image_sha256",
 
109
  "image_to_png_bytes",
110
  "load_image",
111
  "resize_for_preview",
 
4
 
5
  import hashlib
6
  import io
7
+ import base64
8
  from pathlib import Path
9
  from typing import Any, Iterable
10
 
 
47
  return buf.getvalue()
48
 
49
 
50
+ def image_to_data_uri(
51
+ image: Image.Image,
52
+ *,
53
+ max_side: int = 1800,
54
+ image_format: str = "JPEG",
55
+ quality: int = 92,
56
+ ) -> str:
57
+ """Return a browser-openable image data URI for review previews."""
58
+ pil = resize_for_preview(load_image(image), max_side=max_side)
59
+ fmt = image_format.upper()
60
+ buf = io.BytesIO()
61
+ if fmt in {"JPG", "JPEG"}:
62
+ pil = pil.convert("RGB")
63
+ pil.save(buf, format="JPEG", quality=quality, optimize=True)
64
+ mime = "image/jpeg"
65
+ elif fmt == "PNG":
66
+ pil.save(buf, format="PNG", optimize=True)
67
+ mime = "image/png"
68
+ else:
69
+ raise ValueError(f"unsupported image_format: {image_format}")
70
+ encoded = base64.b64encode(buf.getvalue()).decode("ascii")
71
+ return f"data:{mime};base64,{encoded}"
72
+
73
+
74
  def image_sha256(image: Image.Image | bytes) -> str:
75
  if isinstance(image, bytes):
76
  payload = image
 
131
  "LABEL_STYLE",
132
  "draw_defects",
133
  "image_sha256",
134
+ "image_to_data_uri",
135
  "image_to_png_bytes",
136
  "load_image",
137
  "resize_for_preview",
data/schemas.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from dataclasses import dataclass
6
  from typing import Any, Iterable
7
 
@@ -18,7 +19,6 @@ ALLOWED_LABELS = frozenset(
18
  }
19
  )
20
 
21
- MIN_DEFECT_CONFIDENCE = 0.35
22
  DEDUP_IOU_THRESHOLD = 0.72
23
 
24
  LABEL_DISPLAY_NAMES = {
@@ -46,6 +46,17 @@ DEFECT_CLASSES_KNOWN = {
46
  BBox = tuple[float, float, float, float]
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
49
  @dataclass(frozen=True)
50
  class Defect:
51
  label: str
@@ -102,6 +113,12 @@ def normalize_bbox(bbox: Any) -> BBox | None:
102
  y_min /= scale
103
  x_max /= scale
104
  y_max /= scale
 
 
 
 
 
 
105
 
106
  if not all(0.0 <= v <= 1.0 for v in (x_min, y_min, x_max, y_max)):
107
  return None
@@ -133,9 +150,38 @@ def validate_defect(raw: Any, min_confidence: float = MIN_DEFECT_CONFIDENCE) ->
133
  confidence = None
134
  if confidence is not None and confidence < min_confidence:
135
  return None
 
 
136
  return Defect(label=label, bbox=bbox, confidence=confidence)
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def clean_defects(
140
  raw_defects: Any,
141
  min_confidence: float = MIN_DEFECT_CONFIDENCE,
 
2
 
3
  from __future__ import annotations
4
 
5
+ import os
6
  from dataclasses import dataclass
7
  from typing import Any, Iterable
8
 
 
19
  }
20
  )
21
 
 
22
  DEDUP_IOU_THRESHOLD = 0.72
23
 
24
  LABEL_DISPLAY_NAMES = {
 
46
  BBox = tuple[float, float, float, float]
47
 
48
 
49
+ def _env_float(name: str, default: float) -> float:
50
+ try:
51
+ return float(os.getenv(name, str(default)))
52
+ except (TypeError, ValueError):
53
+ return default
54
+
55
+
56
+ SUBJECT_HAIR_CONFIDENCE_MAX = 0.5
57
+ MIN_DEFECT_CONFIDENCE = _env_float("HALIDE_MIN_DEFECT_CONFIDENCE", 0.45)
58
+
59
+
60
  @dataclass(frozen=True)
61
  class Defect:
62
  label: str
 
113
  y_min /= scale
114
  x_max /= scale
115
  y_max /= scale
116
+ if not all(-0.001 <= v <= 1.002 for v in (x_min, y_min, x_max, y_max)):
117
+ return None
118
+ x_min = max(0.0, min(1.0, x_min))
119
+ y_min = max(0.0, min(1.0, y_min))
120
+ x_max = max(0.0, min(1.0, x_max))
121
+ y_max = max(0.0, min(1.0, y_max))
122
 
123
  if not all(0.0 <= v <= 1.0 for v in (x_min, y_min, x_max, y_max)):
124
  return None
 
150
  confidence = None
151
  if confidence is not None and confidence < min_confidence:
152
  return None
153
+ if is_likely_subject_hair(label, bbox, confidence):
154
+ return None
155
  return Defect(label=label, bbox=bbox, confidence=confidence)
156
 
157
 
158
+ def is_likely_subject_hair(
159
+ label: str,
160
+ bbox: BBox,
161
+ confidence: float | None,
162
+ ) -> bool:
163
+ """Drop central hair-like subject detail before it reaches diagnosis."""
164
+ if label not in {"long_hair", "short_hair"}:
165
+ return False
166
+ if confidence is not None and confidence >= SUBJECT_HAIR_CONFIDENCE_MAX:
167
+ return False
168
+
169
+ x_min, y_min, x_max, y_max = bbox
170
+ width = x_max - x_min
171
+ height = y_max - y_min
172
+ if width <= 0 or height <= 0:
173
+ return False
174
+
175
+ aspect_ratio = max(width / height, height / width)
176
+ fully_inside_subject_zone = (
177
+ x_min > 0.16
178
+ and x_max < 0.84
179
+ and y_min > 0.10
180
+ and y_max < 0.90
181
+ )
182
+ return fully_inside_subject_zone and aspect_ratio >= 7.5
183
+
184
+
185
  def clean_defects(
186
  raw_defects: Any,
187
  min_confidence: float = MIN_DEFECT_CONFIDENCE,
models/vision/inference.py CHANGED
@@ -7,7 +7,7 @@ from pathlib import Path
7
  from typing import Any
8
 
9
  from config import get_vision_config
10
- from data.schemas import clean_defects, dedupe_defects, label_counts
11
  from data.preprocessing import load_image
12
  from models.vision.minicpm_wrapper import get_detector
13
 
@@ -21,15 +21,48 @@ def extract_defects(image: Any) -> dict:
21
  input_image = load_image(image)
22
  model_image, resized_for_model = _resize_for_model(input_image)
23
  raw = detector.detect(model_image)
24
- elapsed = time.perf_counter() - started
25
 
26
  if not isinstance(raw, dict):
27
  logger.warning("Model output is not a dict: %r", type(raw))
28
  raw = {"defects": [], "_parse_error": "non_dict_output"}
29
 
30
  cleaned, dropped = clean_defects(raw.get("defects", []))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  cleaned, duplicate_count = dedupe_defects(cleaned)
32
  counts = label_counts(cleaned)
 
33
 
34
  return {
35
  "defects": cleaned,
@@ -41,6 +74,10 @@ def extract_defects(image: Any) -> dict:
41
  "model_path": detector.model_path,
42
  "parse_error": raw.get("_parse_error"),
43
  "resized_for_model": resized_for_model,
 
 
 
 
44
  }
45
 
46
 
@@ -64,3 +101,105 @@ def _resize_for_model(image: Any) -> tuple[Any, bool]:
64
  max(1, int(round(height * scale))),
65
  )
66
  return image.resize(new_size), True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from typing import Any
8
 
9
  from config import get_vision_config
10
+ from data.schemas import BBox, clean_defects, dedupe_defects, label_counts, normalize_bbox
11
  from data.preprocessing import load_image
12
  from models.vision.minicpm_wrapper import get_detector
13
 
 
21
  input_image = load_image(image)
22
  model_image, resized_for_model = _resize_for_model(input_image)
23
  raw = detector.detect(model_image)
 
24
 
25
  if not isinstance(raw, dict):
26
  logger.warning("Model output is not a dict: %r", type(raw))
27
  raw = {"defects": [], "_parse_error": "non_dict_output"}
28
 
29
  cleaned, dropped = clean_defects(raw.get("defects", []))
30
+ full_frame_count = len(cleaned)
31
+ tile_fallback_used = False
32
+ tile_count = 0
33
+ tile_parse_errors: list[str] = []
34
+
35
+ cfg = get_vision_config()
36
+ if _should_run_tile_fallback(input_image, cleaned):
37
+ tile_fallback_used = True
38
+ tile_defects: list[dict[str, Any]] = list(cleaned)
39
+ for tile_index, (tile_image, tile_box) in enumerate(_iter_tiles(input_image), start=1):
40
+ tile_count = tile_index
41
+ tile_model_image, tile_resized = _resize_for_model(tile_image)
42
+ resized_for_model = resized_for_model or tile_resized
43
+ tile_raw = detector.detect(tile_model_image)
44
+ if not isinstance(tile_raw, dict):
45
+ tile_parse_errors.append("non_dict_output")
46
+ dropped += 1
47
+ continue
48
+ if tile_raw.get("_parse_error"):
49
+ tile_parse_errors.append(str(tile_raw.get("_parse_error")))
50
+ tile_cleaned, tile_dropped = clean_defects(tile_raw.get("defects", []))
51
+ dropped += tile_dropped
52
+ tile_defects.extend(
53
+ _remap_tile_defects(
54
+ tile_cleaned,
55
+ tile_box=tile_box,
56
+ image_size=input_image.size,
57
+ )
58
+ )
59
+ if tile_count >= max(1, int(cfg.tile_max_tiles)):
60
+ break
61
+ cleaned = tile_defects
62
+
63
  cleaned, duplicate_count = dedupe_defects(cleaned)
64
  counts = label_counts(cleaned)
65
+ elapsed = time.perf_counter() - started
66
 
67
  return {
68
  "defects": cleaned,
 
74
  "model_path": detector.model_path,
75
  "parse_error": raw.get("_parse_error"),
76
  "resized_for_model": resized_for_model,
77
+ "tile_fallback_used": tile_fallback_used,
78
+ "tile_count": tile_count,
79
+ "full_frame_defect_count": full_frame_count,
80
+ "tile_parse_errors": tile_parse_errors,
81
  }
82
 
83
 
 
101
  max(1, int(round(height * scale))),
102
  )
103
  return image.resize(new_size), True
104
+
105
+
106
+ def _should_run_tile_fallback(image: Any, defects: list[dict[str, Any]]) -> bool:
107
+ cfg = get_vision_config()
108
+ if not cfg.tile_fallback_enabled:
109
+ return False
110
+ if len(defects) >= max(0, int(cfg.tile_fallback_min_defects)):
111
+ return False
112
+ width, height = image.size
113
+ if max(width, height) < max(1, int(cfg.tile_min_side)):
114
+ return False
115
+ return True
116
+
117
+
118
+ def _iter_tiles(image: Any) -> list[tuple[Any, tuple[int, int, int, int]]]:
119
+ cfg = get_vision_config()
120
+ width, height = image.size
121
+ tile_side = min(max(1, int(cfg.tile_max_side)), max(width, height))
122
+ tile_width = min(width, tile_side)
123
+ tile_height = min(height, tile_side)
124
+ overlap = max(0.0, min(0.85, float(cfg.tile_overlap)))
125
+ xs = _axis_positions(width, tile_width, overlap)
126
+ ys = _axis_positions(height, tile_height, overlap)
127
+ tiles: list[tuple[Any, tuple[int, int, int, int]]] = []
128
+ center = ((width - tile_width) // 2, (height - tile_height) // 2)
129
+ ordered_positions = [(x, y) for y in ys for x in xs]
130
+ ordered_positions.insert(0, center)
131
+ seen: set[tuple[int, int]] = set()
132
+ for x, y in ordered_positions:
133
+ x = max(0, min(width - tile_width, x))
134
+ y = max(0, min(height - tile_height, y))
135
+ if (x, y) in seen:
136
+ continue
137
+ seen.add((x, y))
138
+ box = (x, y, x + tile_width, y + tile_height)
139
+ tiles.append((image.crop(box), box))
140
+ if len(tiles) >= max(1, int(cfg.tile_max_tiles)):
141
+ break
142
+ return tiles
143
+
144
+
145
+ def _axis_positions(length: int, tile_length: int, overlap: float) -> list[int]:
146
+ if length <= tile_length:
147
+ return [0]
148
+ stride = max(1, int(round(tile_length * (1.0 - overlap))))
149
+ limit = length - tile_length
150
+ positions = list(range(0, limit + 1, stride))
151
+ positions.extend([limit, limit // 2])
152
+ return sorted(set(max(0, min(limit, pos)) for pos in positions))
153
+
154
+
155
+ def _remap_tile_defects(
156
+ defects: list[dict[str, Any]],
157
+ *,
158
+ tile_box: tuple[int, int, int, int],
159
+ image_size: tuple[int, int],
160
+ ) -> list[dict[str, Any]]:
161
+ image_width, image_height = image_size
162
+ x0, y0, x1, y1 = tile_box
163
+ tile_width = max(1, x1 - x0)
164
+ tile_height = max(1, y1 - y0)
165
+ remapped: list[dict[str, Any]] = []
166
+ for defect in defects:
167
+ bbox = normalize_bbox(defect.get("bbox"))
168
+ if bbox is None:
169
+ continue
170
+ gx0, gy0, gx1, gy1 = _remap_bbox(
171
+ bbox,
172
+ x0=x0,
173
+ y0=y0,
174
+ tile_width=tile_width,
175
+ tile_height=tile_height,
176
+ image_width=image_width,
177
+ image_height=image_height,
178
+ )
179
+ out = {
180
+ "label": defect.get("label"),
181
+ "bbox": [gx0, gy0, gx1, gy1],
182
+ }
183
+ if defect.get("confidence") is not None:
184
+ out["confidence"] = defect.get("confidence")
185
+ remapped.append(out)
186
+ return remapped
187
+
188
+
189
+ def _remap_bbox(
190
+ bbox: BBox,
191
+ *,
192
+ x0: int,
193
+ y0: int,
194
+ tile_width: int,
195
+ tile_height: int,
196
+ image_width: int,
197
+ image_height: int,
198
+ ) -> BBox:
199
+ bx0, by0, bx1, by1 = bbox
200
+ return (
201
+ round((x0 + bx0 * tile_width) / image_width, 6),
202
+ round((y0 + by0 * tile_height) / image_height, 6),
203
+ round((x0 + bx1 * tile_width) / image_width, 6),
204
+ round((y0 + by1 * tile_height) / image_height, 6),
205
+ )
models/vision/minicpm_wrapper.py CHANGED
@@ -9,33 +9,11 @@ import re
9
  from typing import Any
10
 
11
  from config import CHECKPOINT_DIR, get_vision_config, require_gpu_for_inference
 
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
- DETECTION_PROMPT = (
16
- "You are a film defect detection engine. Analyze the film scan and detect "
17
- "only physical defects that are on the film, scanner glass, holder, or "
18
- "scan surface. The image may be a positive film scan of an ordinary scene, "
19
- "a negative, a slide, a contact sheet, or a film scanner output. Detect "
20
- "defects that appear as dust spots, dirt blobs, thin abrasion lines, "
21
- "hair-like overlays, emulsion loss, chemical stains, or light leaks on "
22
- "top of the photographed content. If no clear "
23
- "surface artifact is visible, return {\"defects\": []}. Do not label "
24
- "subject matter as defects. Do not label grass, tree branches, eyelashes, "
25
- "fabric fibers, texture, grain, wires, shadows, printed text, or real hair "
26
- "inside the photographed scene as long_hair or short_hair. Use scratch "
27
- "only for thin physical abrasion or scan-surface lines, not object edges, "
28
- "stems, typography, or composition lines. Output a JSON object with a "
29
- "'defects' array. Each defect has: "
30
- "'label' (dust, dirt, scratch, long_hair, short_hair, emulsion_damage, "
31
- "chemical_stain, light_leak), "
32
- "optional 'confidence' from 0.0 to 1.0, "
33
- "'bbox' as 4 integers in the [0, 999] grid "
34
- "[x_min, y_min, x_max, y_max] (multiply by image width/height to get pixels). "
35
- "Return at most 150 defects. Prefer the clearest defects. Do not repeat "
36
- "the same label and bbox. If uncertain, return an empty defects array. "
37
- "Output JSON only, no explanation."
38
- )
39
 
40
 
41
  def _resolve_model_path() -> str:
 
9
  from typing import Any
10
 
11
  from config import CHECKPOINT_DIR, get_vision_config, require_gpu_for_inference
12
+ from models.vision.prompts import DETECTION_PROMPT_INT
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
+ DETECTION_PROMPT = DETECTION_PROMPT_INT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def _resolve_model_path() -> str:
models/vision/prompts.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared prompts for MiniCPM-V film defect extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DEFECT_LABELS = (
6
+ "dust, dirt, scratch, long_hair, short_hair, emulsion_damage, "
7
+ "chemical_stain, light_leak"
8
+ )
9
+
10
+ DETECTION_PROMPT_BASE = (
11
+ "You are a film defect detection engine. Analyze the film scan and detect "
12
+ "only physical defects that are on the film, scanner glass, holder, or "
13
+ "scan surface. The image may be a positive film scan of an ordinary scene, "
14
+ "a negative, a slide, a contact sheet, or a film scanner output. Detect "
15
+ "defects that appear as dust spots, dirt blobs, thin abrasion lines, "
16
+ "hair-like overlays, emulsion loss, chemical stains, or light leaks on "
17
+ "top of the photographed content. Return {\"defects\": []} only when no "
18
+ "visible surface artifact is present. Do not return an empty array when "
19
+ "obvious dark or light scratches, cracks, abrasion lines, peeled emulsion, "
20
+ "or opaque dirt cross the subject content. Do not label subject matter as "
21
+ "defects. Do not label grass, tree branches, eyelashes, fabric fibers, "
22
+ "texture, grain, wires, shadows, printed text, or real hair inside the "
23
+ "photographed scene as long_hair or short_hair. Use scratch only for thin "
24
+ "physical abrasion or scan-surface lines, not object edges, stems, "
25
+ "typography, or composition lines. Transparent or semi-transparent crack "
26
+ "networks, lifted film bands, broken coating sheets, and fracture lines "
27
+ "crossing a face or subject are defects, not scene content. Use "
28
+ "emulsion_damage for broad peeled, cracked, torn, lifted, or missing "
29
+ "emulsion regions. Use scratch for fine crack branches and abrasion "
30
+ "lines. Output a JSON object with a "
31
+ "'defects' array. Each defect has: "
32
+ f"'label' ({DEFECT_LABELS}), "
33
+ "optional 'confidence' from 0.0 to 1.0, "
34
+ )
35
+
36
+ DETECTION_PROMPT_SUFFIX = (
37
+ "Return at most 150 defects. Prefer the clearest defects. Do not repeat "
38
+ "the same label and bbox. Output JSON only, no explanation."
39
+ )
40
+
41
+ DETECTION_PROMPT_INT = (
42
+ DETECTION_PROMPT_BASE
43
+ + "'bbox' as 4 integers in the [0, 999] grid "
44
+ + "[x_min, y_min, x_max, y_max] (multiply by image width/height to get pixels). "
45
+ + DETECTION_PROMPT_SUFFIX
46
+ )
47
+
48
+ DETECTION_PROMPT_FLOAT = (
49
+ DETECTION_PROMPT_BASE
50
+ + "'bbox' (normalized [x_min, y_min, x_max, y_max] from 0.0 to 1.0). "
51
+ + DETECTION_PROMPT_SUFFIX
52
+ )
ui/app.py CHANGED
@@ -7,16 +7,22 @@ import logging
7
  from typing import Any
8
 
9
  import gradio as gr
10
- from PIL import Image, ImageDraw, ImageFont
11
 
12
  from config import get_app_config
13
- from data.preprocessing import draw_defects, image_to_png_bytes, load_image
 
 
 
 
 
14
  from pipeline.pipeline import run_diagnosis
15
  from storage.cache import get_cache
16
  from storage.database import get_diagnosis, init_db, list_recent, record_diagnosis
17
  from ui.components import (
18
  EMPTY_STATE,
19
  HEADER_HTML,
 
 
20
  REPORT_EMPTY_STATE,
21
  confidence_notice_html,
22
  defect_table_rows,
@@ -26,8 +32,8 @@ from ui.components import (
26
  history_detail_html,
27
  history_table_rows,
28
  metadata_html,
29
- render_history,
30
  raw_json_text,
 
31
  run_state_html,
32
  stats_html,
33
  )
@@ -77,6 +83,24 @@ METADATA_CONFIDENCE_OPTIONS = [
77
  "High, verified from notes or edge marks",
78
  ]
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  def normalize_metadata_confidence(value: str | None) -> str:
82
  text = (value or "low").strip().lower()
@@ -89,24 +113,26 @@ def normalize_metadata_confidence(value: str | None) -> str:
89
 
90
  def _history_state(
91
  selected_id: str | None = None,
92
- ) -> tuple[str, dict | None, Any, list[list[str]]]:
93
  entries = list_recent(limit=get_app_config().max_history_items)
94
  choices = history_choices(entries)
95
  ids = [value for _label, value in choices]
96
  value = selected_id if selected_id in ids else (ids[0] if ids else None)
97
  selected = next((entry for entry in entries if entry.get("id") == value), None)
98
- return render_history(entries), selected, gr.update(choices=choices, value=value), history_table_rows(entries)
99
 
100
 
101
  def _empty_outputs(
102
  message: str = "Awaiting scan.",
103
- ) -> tuple[Any, Any, str, str, str, str, str, str, str, list[list[str]], str, str, Any, list[list[str]]]:
104
- history_html, selected_entry, selector_update, history_rows = _history_state()
105
  empty = f'<p class="halide-muted">{html.escape(message)}</p>'
106
  hidden_html = gr.update(value="", visible=False)
107
  return (
108
- None,
 
109
  gr.update(value=[], visible=False),
 
110
  run_state_html(None),
111
  empty,
112
  empty,
@@ -115,7 +141,6 @@ def _empty_outputs(
115
  "",
116
  "{}",
117
  [],
118
- history_html,
119
  history_detail_html(selected_entry),
120
  selector_update,
121
  history_rows,
@@ -129,42 +154,48 @@ def _review_gallery(pil_image: Any, annotated: Any) -> list[tuple[Any, str]]:
129
  ]
130
 
131
 
132
- def _placeholder_image() -> Image.Image:
133
- image = Image.new("RGB", (1200, 760), (5, 5, 5))
134
- draw = ImageDraw.Draw(image)
135
- frame = (28, 28, 1172, 732)
136
- draw.rectangle(frame, outline=(68, 62, 52), width=2)
137
- draw.rectangle((48, 48, 1152, 712), outline=(28, 27, 25), width=1)
138
- try:
139
- font = ImageFont.load_default(size=32)
140
- small = ImageFont.load_default(size=18)
141
- except TypeError:
142
- font = ImageFont.load_default()
143
- small = font
144
- title = "Awaiting scan"
145
- subtitle = "Validated overlay will appear here"
146
- title_box = draw.textbbox((0, 0), title, font=font)
147
- subtitle_box = draw.textbbox((0, 0), subtitle, font=small)
148
- center_x = image.width // 2
149
- center_y = image.height // 2
150
- draw.text(
151
- (center_x - (title_box[2] - title_box[0]) // 2, center_y - 26),
152
- title,
153
- fill=(243, 234, 219),
154
- font=font,
155
- )
156
- draw.text(
157
- (center_x - (subtitle_box[2] - subtitle_box[0]) // 2, center_y + 18),
158
- subtitle,
159
- fill=(169, 155, 136),
160
- font=small,
161
  )
162
- return image
163
 
164
 
165
- def _placeholder_pair() -> tuple[Image.Image, Image.Image]:
166
- placeholder = _placeholder_image()
167
- return placeholder, placeholder.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
 
170
  @_gpu_decorator()
@@ -176,7 +207,7 @@ def run_pipeline(
176
  scan_dpi: int,
177
  metadata_confidence: str = "low",
178
  progress: gr.Progress = gr.Progress(),
179
- ) -> tuple[Any, Any, str, str, str, str, str, str, str, list[list[str]], str, str, Any, list[list[str]]]:
180
  """Gradio handler for the diagnose button."""
181
  if image is None:
182
  return _empty_outputs("No image provided.")
@@ -194,10 +225,12 @@ def run_pipeline(
194
  "metadata_confidence": normalize_metadata_confidence(metadata_confidence),
195
  }
196
  cached = cache.get(image_bytes, metadata=metadata)
 
197
  if cached is not None:
198
  logger.info("Returning cached diagnosis")
199
  result = cached
200
  else:
 
201
  progress(0.1, "Stage 1/2: running vision defect extraction...")
202
  result = run_diagnosis(
203
  image=pil_image,
@@ -208,12 +241,6 @@ def run_pipeline(
208
  metadata_confidence=metadata["metadata_confidence"],
209
  )
210
  progress(0.85, "Stage 2/2: persisting diagnosis...")
211
- try:
212
- diagnosis_id = record_diagnosis(result)
213
- result["diagnosis_id"] = diagnosis_id
214
- except Exception as exc: # pragma: no cover
215
- logger.warning("Failed to record diagnosis: %s", exc)
216
- cache.put(image_bytes, result, metadata=metadata)
217
 
218
  progress(1.0, "Done.")
219
 
@@ -224,8 +251,24 @@ def run_pipeline(
224
  defects,
225
  title=f"Halide: {len(defects)} validated defects",
226
  )
 
 
 
 
 
 
 
 
 
 
 
227
  image_pair = (pil_image, annotated)
 
228
  gallery = gr.update(value=_review_gallery(pil_image, annotated), visible=True)
 
 
 
 
229
  run_state = run_state_html(result)
230
  stats = stats_html(result)
231
  notice = confidence_notice_html(result)
@@ -237,10 +280,12 @@ def run_pipeline(
237
  meta = metadata_html(result)
238
  raw_json = raw_json_text(result)
239
  table_rows = defect_table_rows(result)
240
- history, selected_entry, selector_update, history_rows = _history_state(result.get("diagnosis_id"))
241
  return (
242
- image_pair,
 
243
  gallery,
 
244
  run_state,
245
  stats,
246
  notice,
@@ -249,25 +294,20 @@ def run_pipeline(
249
  meta,
250
  raw_json,
251
  table_rows,
252
- history,
253
  history_detail_html(selected_entry),
254
  selector_update,
255
  history_rows,
256
  )
257
  except Exception as exc: # pragma: no cover
258
  logger.exception("Pipeline failed")
259
- err = (
260
- '<div class="halide-panel" style="border-color: var(--halide-red);">'
261
- f'<div class="halide-section-title" style="color: var(--halide-red);">'
262
- f"Pipeline error</div>"
263
- f"<pre style=\"color: var(--halide-text); white-space: pre-wrap;\">"
264
- f"{html.escape(str(exc))}</pre></div>"
265
- )
266
- history, selected_entry, selector_update, history_rows = _history_state()
267
  hidden_html = gr.update(value="", visible=False)
268
  return (
269
- None,
 
270
  gr.update(value=[], visible=False),
 
271
  err,
272
  err,
273
  "",
@@ -276,18 +316,16 @@ def run_pipeline(
276
  "",
277
  "{}",
278
  [],
279
- history,
280
  history_detail_html(selected_entry),
281
  selector_update,
282
  history_rows,
283
  )
284
 
285
 
286
- def refresh_history(selected_id: str | None = None) -> tuple[Any, str, str, str, list[list[str]]]:
287
- history, selected_entry, selector_update, history_rows = _history_state(selected_id)
288
  return (
289
  selector_update,
290
- history,
291
  history_detail_html(selected_entry),
292
  raw_json_text(selected_entry),
293
  history_rows,
@@ -299,9 +337,35 @@ def open_history(diagnosis_id: str | None) -> tuple[str, str]:
299
  return history_detail_html(entry), raw_json_text(entry)
300
 
301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  def build_app() -> gr.Blocks:
303
  init_db()
304
- history_html, selected_entry, selector_update, initial_history_rows = _history_state()
305
  initial_choices = selector_update["choices"] if isinstance(selector_update, dict) else []
306
  initial_value = selector_update["value"] if isinstance(selector_update, dict) else None
307
 
@@ -322,7 +386,7 @@ def build_app() -> gr.Blocks:
322
  image_input = gr.Image(
323
  label="Film scan",
324
  type="pil",
325
- height=300,
326
  sources=["upload", "clipboard"],
327
  buttons=["download", "fullscreen"],
328
  elem_classes="halide-upload",
@@ -333,29 +397,30 @@ def build_app() -> gr.Blocks:
333
  label="Film stock",
334
  allow_custom_value=True,
335
  )
336
- with gr.Row(elem_classes="halide-inline-controls"):
337
- film_age = gr.Slider(
338
- minimum=0,
339
- maximum=80,
340
- step=1,
341
- value=0,
342
- label="Age",
343
- buttons=["reset"],
344
- )
345
- scan_dpi = gr.Dropdown(
346
- choices=RESOLUTION_OPTIONS,
347
- value=4000,
348
- label="DPI",
349
- )
350
  storage = gr.Radio(
351
  choices=STORAGE_OPTIONS,
352
  value=STORAGE_OPTIONS[0],
353
  label="Storage",
354
  )
355
- metadata_confidence = gr.Radio(
356
  choices=METADATA_CONFIDENCE_OPTIONS,
357
  value=METADATA_CONFIDENCE_OPTIONS[0],
358
  label="Metadata confidence",
 
359
  )
360
  run_btn = gr.Button(
361
  "Diagnose scan",
@@ -381,16 +446,18 @@ def build_app() -> gr.Blocks:
381
  '<small>Review</small>'
382
  "</div>"
383
  )
 
384
  compare_output = gr.ImageSlider(
385
- value=_placeholder_pair(),
386
  label="Original / overlay",
387
  type="pil",
388
- height="54vh",
389
- max_height=760,
390
  slider_position=52,
391
  interactive=False,
392
  buttons=["download", "fullscreen"],
393
  elem_id="halide-compare",
 
394
  )
395
  review_gallery = gr.Gallery(
396
  value=[],
@@ -404,6 +471,7 @@ def build_app() -> gr.Blocks:
404
  elem_classes="halide-review-gallery",
405
  visible=False,
406
  )
 
407
 
408
  with gr.Column(scale=3, min_width=390, elem_classes="halide-inspector"):
409
  with gr.Tabs(selected="report", elem_classes="halide-inspector-tabs"):
@@ -425,13 +493,18 @@ def build_app() -> gr.Blocks:
425
  max_height=300,
426
  )
427
  with gr.Tab("History", id="history"):
 
 
 
428
  history_select = gr.Dropdown(
429
  choices=initial_choices,
430
  value=initial_value,
431
  label="Saved diagnosis",
432
  interactive=True,
433
  )
434
- refresh_btn = gr.Button("Refresh", size="sm")
 
 
435
  history_table = gr.Dataframe(
436
  value=initial_history_rows,
437
  headers=["Saved", "Film stock", "Defects", "Labels", "ID"],
@@ -441,14 +514,11 @@ def build_app() -> gr.Blocks:
441
  interactive=False,
442
  wrap=True,
443
  max_height=260,
 
444
  )
445
  history_detail = gr.HTML(
446
  value=history_detail_html(selected_entry)
447
  )
448
- history_output = gr.HTML(
449
- value=history_html,
450
- elem_classes="halide-history-feed",
451
- )
452
  with gr.Tab("JSON", id="json"):
453
  raw_output = gr.Code(
454
  value="{}",
@@ -466,8 +536,8 @@ def build_app() -> gr.Blocks:
466
  )
467
 
468
  run_event = run_btn.click(
469
- fn=lambda: gr.update(interactive=False, value="Diagnosing..."),
470
- outputs=[run_btn],
471
  queue=False,
472
  )
473
  run_event.then(
@@ -481,8 +551,10 @@ def build_app() -> gr.Blocks:
481
  metadata_confidence,
482
  ],
483
  outputs=[
 
484
  compare_output,
485
  review_gallery,
 
486
  run_state_output,
487
  stats_output,
488
  notice_output,
@@ -491,7 +563,6 @@ def build_app() -> gr.Blocks:
491
  metadata_output,
492
  raw_output,
493
  defect_table,
494
- history_output,
495
  history_detail,
496
  history_select,
497
  history_table,
@@ -504,13 +575,23 @@ def build_app() -> gr.Blocks:
504
  refresh_btn.click(
505
  fn=refresh_history,
506
  inputs=[history_select],
507
- outputs=[history_select, history_output, history_detail, raw_output, history_table],
 
 
 
 
 
508
  )
509
  history_select.change(
510
  fn=open_history,
511
  inputs=[history_select],
512
  outputs=[history_detail, raw_output],
513
  )
 
 
 
 
 
514
 
515
  return app
516
 
 
7
  from typing import Any
8
 
9
  import gradio as gr
 
10
 
11
  from config import get_app_config
12
+ from data.preprocessing import (
13
+ draw_defects,
14
+ image_to_data_uri,
15
+ image_to_png_bytes,
16
+ load_image,
17
+ )
18
  from pipeline.pipeline import run_diagnosis
19
  from storage.cache import get_cache
20
  from storage.database import get_diagnosis, init_db, list_recent, record_diagnosis
21
  from ui.components import (
22
  EMPTY_STATE,
23
  HEADER_HTML,
24
+ LIGHTTABLE_EMPTY_STATE,
25
+ LIGHTTABLE_RUNNING_STATE,
26
  REPORT_EMPTY_STATE,
27
  confidence_notice_html,
28
  defect_table_rows,
 
32
  history_detail_html,
33
  history_table_rows,
34
  metadata_html,
 
35
  raw_json_text,
36
+ review_frame_html,
37
  run_state_html,
38
  stats_html,
39
  )
 
83
  "High, verified from notes or edge marks",
84
  ]
85
 
86
+ PipelineOutputs = tuple[
87
+ Any,
88
+ Any,
89
+ Any,
90
+ Any,
91
+ str,
92
+ str,
93
+ str,
94
+ str,
95
+ str,
96
+ str,
97
+ str,
98
+ list[list[str]],
99
+ str,
100
+ Any,
101
+ list[list[str]],
102
+ ]
103
+
104
 
105
  def normalize_metadata_confidence(value: str | None) -> str:
106
  text = (value or "low").strip().lower()
 
113
 
114
  def _history_state(
115
  selected_id: str | None = None,
116
+ ) -> tuple[dict | None, Any, list[list[str]]]:
117
  entries = list_recent(limit=get_app_config().max_history_items)
118
  choices = history_choices(entries)
119
  ids = [value for _label, value in choices]
120
  value = selected_id if selected_id in ids else (ids[0] if ids else None)
121
  selected = next((entry for entry in entries if entry.get("id") == value), None)
122
+ return selected, gr.update(choices=choices, value=value), history_table_rows(entries)
123
 
124
 
125
  def _empty_outputs(
126
  message: str = "Awaiting scan.",
127
+ ) -> PipelineOutputs:
128
+ selected_entry, selector_update, history_rows = _history_state()
129
  empty = f'<p class="halide-muted">{html.escape(message)}</p>'
130
  hidden_html = gr.update(value="", visible=False)
131
  return (
132
+ gr.update(value=LIGHTTABLE_EMPTY_STATE, visible=True),
133
+ gr.update(value=None, visible=False),
134
  gr.update(value=[], visible=False),
135
+ hidden_html,
136
  run_state_html(None),
137
  empty,
138
  empty,
 
141
  "",
142
  "{}",
143
  [],
 
144
  history_detail_html(selected_entry),
145
  selector_update,
146
  history_rows,
 
154
  ]
155
 
156
 
157
+ def _running_button_state() -> tuple[Any, str, Any]:
158
+ return (
159
+ gr.update(interactive=False, value="Diagnosing..."),
160
+ (
161
+ '<div class="halide-run-state active">'
162
+ '<span class="halide-run-eyebrow">Running</span>'
163
+ "<strong>GPU inspection in progress</strong>"
164
+ "<span>Vision extraction, validation, and report generation are running.</span>"
165
+ "</div>"
166
+ ),
167
+ gr.update(value=LIGHTTABLE_RUNNING_STATE, visible=True),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  )
 
169
 
170
 
171
+ def _attach_preview(result: dict, pil_image: Any, annotated: Any) -> dict:
172
+ result = dict(result)
173
+ if not isinstance(result.get("preview"), dict):
174
+ result["preview"] = {
175
+ "original": image_to_data_uri(pil_image, max_side=720, quality=86),
176
+ "overlay": image_to_data_uri(annotated, max_side=720, quality=86),
177
+ }
178
+ return result
179
+
180
+
181
+ def pipeline_error_html(exc: Exception) -> str:
182
+ text = str(exc)
183
+ lower = text.lower()
184
+ if "no cuda gpu" in lower or "cuda" in lower or "gpu" in lower:
185
+ title = "GPU unavailable"
186
+ body = (
187
+ "The diagnosis needs a live GPU slot. Please retry in a moment, "
188
+ "or run the app on a GPU-backed Space."
189
+ )
190
+ else:
191
+ title = "Pipeline error"
192
+ body = text or "The diagnostic pipeline stopped unexpectedly."
193
+ return (
194
+ '<div class="halide-panel" style="border-color: var(--halide-red);">'
195
+ f'<div class="halide-section-title" style="color: var(--halide-red);">'
196
+ f"{html.escape(title)}</div>"
197
+ f"<p class=\"halide-muted\">{html.escape(body)}</p></div>"
198
+ )
199
 
200
 
201
  @_gpu_decorator()
 
207
  scan_dpi: int,
208
  metadata_confidence: str = "low",
209
  progress: gr.Progress = gr.Progress(),
210
+ ) -> PipelineOutputs:
211
  """Gradio handler for the diagnose button."""
212
  if image is None:
213
  return _empty_outputs("No image provided.")
 
225
  "metadata_confidence": normalize_metadata_confidence(metadata_confidence),
226
  }
227
  cached = cache.get(image_bytes, metadata=metadata)
228
+ was_cached = cached is not None
229
  if cached is not None:
230
  logger.info("Returning cached diagnosis")
231
  result = cached
232
  else:
233
+ progress(0.05, "Loading GPU models if needed...")
234
  progress(0.1, "Stage 1/2: running vision defect extraction...")
235
  result = run_diagnosis(
236
  image=pil_image,
 
241
  metadata_confidence=metadata["metadata_confidence"],
242
  )
243
  progress(0.85, "Stage 2/2: persisting diagnosis...")
 
 
 
 
 
 
244
 
245
  progress(1.0, "Done.")
246
 
 
251
  defects,
252
  title=f"Halide: {len(defects)} validated defects",
253
  )
254
+ result = _attach_preview(result, pil_image, annotated)
255
+ if not was_cached:
256
+ try:
257
+ diagnosis_id = record_diagnosis(result)
258
+ result["diagnosis_id"] = diagnosis_id
259
+ except Exception as exc: # pragma: no cover
260
+ logger.warning("Failed to record diagnosis: %s", exc)
261
+ cache.put(image_bytes, result, metadata=metadata)
262
+ elif not result.get("diagnosis_id"):
263
+ cache.put(image_bytes, result, metadata=metadata)
264
+
265
  image_pair = (pil_image, annotated)
266
+ compare = gr.update(value=image_pair, visible=True)
267
  gallery = gr.update(value=_review_gallery(pil_image, annotated), visible=True)
268
+ review_links = gr.update(
269
+ value=review_frame_html(pil_image, annotated),
270
+ visible=True,
271
+ )
272
  run_state = run_state_html(result)
273
  stats = stats_html(result)
274
  notice = confidence_notice_html(result)
 
280
  meta = metadata_html(result)
281
  raw_json = raw_json_text(result)
282
  table_rows = defect_table_rows(result)
283
+ selected_entry, selector_update, history_rows = _history_state(result.get("diagnosis_id"))
284
  return (
285
+ gr.update(value="", visible=False),
286
+ compare,
287
  gallery,
288
+ review_links,
289
  run_state,
290
  stats,
291
  notice,
 
294
  meta,
295
  raw_json,
296
  table_rows,
 
297
  history_detail_html(selected_entry),
298
  selector_update,
299
  history_rows,
300
  )
301
  except Exception as exc: # pragma: no cover
302
  logger.exception("Pipeline failed")
303
+ err = pipeline_error_html(exc)
304
+ selected_entry, selector_update, history_rows = _history_state()
 
 
 
 
 
 
305
  hidden_html = gr.update(value="", visible=False)
306
  return (
307
+ gr.update(value=LIGHTTABLE_EMPTY_STATE, visible=True),
308
+ gr.update(value=None, visible=False),
309
  gr.update(value=[], visible=False),
310
+ hidden_html,
311
  err,
312
  err,
313
  "",
 
316
  "",
317
  "{}",
318
  [],
 
319
  history_detail_html(selected_entry),
320
  selector_update,
321
  history_rows,
322
  )
323
 
324
 
325
+ def refresh_history(selected_id: str | None = None) -> tuple[Any, str, str, list[list[str]]]:
326
+ selected_entry, selector_update, history_rows = _history_state(selected_id)
327
  return (
328
  selector_update,
 
329
  history_detail_html(selected_entry),
330
  raw_json_text(selected_entry),
331
  history_rows,
 
337
  return history_detail_html(entry), raw_json_text(entry)
338
 
339
 
340
+ def _history_id_from_selection(rows: list[list[str]] | None, index: Any) -> str | None:
341
+ if rows is None:
342
+ return None
343
+ row_index: int | None = None
344
+ if isinstance(index, (list, tuple)) and index:
345
+ try:
346
+ row_index = int(index[0])
347
+ except (TypeError, ValueError):
348
+ row_index = None
349
+ elif isinstance(index, int):
350
+ row_index = index
351
+ if row_index is None or row_index < 0 or row_index >= len(rows):
352
+ return None
353
+ row = rows[row_index]
354
+ if len(row) < 5:
355
+ return None
356
+ diagnosis_id = str(row[4] or "").strip()
357
+ return diagnosis_id or None
358
+
359
+
360
+ def open_history_from_table(rows: list[list[str]] | None, evt: gr.SelectData) -> tuple[Any, str, str]:
361
+ diagnosis_id = _history_id_from_selection(rows, getattr(evt, "index", None))
362
+ entry = get_diagnosis(diagnosis_id or "") if diagnosis_id else None
363
+ return gr.update(value=diagnosis_id), history_detail_html(entry), raw_json_text(entry)
364
+
365
+
366
  def build_app() -> gr.Blocks:
367
  init_db()
368
+ selected_entry, selector_update, initial_history_rows = _history_state()
369
  initial_choices = selector_update["choices"] if isinstance(selector_update, dict) else []
370
  initial_value = selector_update["value"] if isinstance(selector_update, dict) else None
371
 
 
386
  image_input = gr.Image(
387
  label="Film scan",
388
  type="pil",
389
+ height=330,
390
  sources=["upload", "clipboard"],
391
  buttons=["download", "fullscreen"],
392
  elem_classes="halide-upload",
 
397
  label="Film stock",
398
  allow_custom_value=True,
399
  )
400
+ film_age = gr.Slider(
401
+ minimum=0,
402
+ maximum=80,
403
+ step=1,
404
+ value=0,
405
+ label="Age (years)",
406
+ buttons=["reset"],
407
+ )
408
+ scan_dpi = gr.Dropdown(
409
+ choices=RESOLUTION_OPTIONS,
410
+ value=4000,
411
+ label="DPI",
412
+ allow_custom_value=True,
413
+ )
414
  storage = gr.Radio(
415
  choices=STORAGE_OPTIONS,
416
  value=STORAGE_OPTIONS[0],
417
  label="Storage",
418
  )
419
+ metadata_confidence = gr.Dropdown(
420
  choices=METADATA_CONFIDENCE_OPTIONS,
421
  value=METADATA_CONFIDENCE_OPTIONS[0],
422
  label="Metadata confidence",
423
+ interactive=True,
424
  )
425
  run_btn = gr.Button(
426
  "Diagnose scan",
 
446
  '<small>Review</small>'
447
  "</div>"
448
  )
449
+ lighttable_empty = gr.HTML(value=LIGHTTABLE_EMPTY_STATE)
450
  compare_output = gr.ImageSlider(
451
+ value=None,
452
  label="Original / overlay",
453
  type="pil",
454
+ height=620,
455
+ max_height=680,
456
  slider_position=52,
457
  interactive=False,
458
  buttons=["download", "fullscreen"],
459
  elem_id="halide-compare",
460
+ visible=False,
461
  )
462
  review_gallery = gr.Gallery(
463
  value=[],
 
471
  elem_classes="halide-review-gallery",
472
  visible=False,
473
  )
474
+ review_links_output = gr.HTML(value="", visible=False)
475
 
476
  with gr.Column(scale=3, min_width=390, elem_classes="halide-inspector"):
477
  with gr.Tabs(selected="report", elem_classes="halide-inspector-tabs"):
 
493
  max_height=300,
494
  )
495
  with gr.Tab("History", id="history"):
496
+ gr.HTML(
497
+ '<div class="halide-tab-note">Select a row or choose a saved run.</div>'
498
+ )
499
  history_select = gr.Dropdown(
500
  choices=initial_choices,
501
  value=initial_value,
502
  label="Saved diagnosis",
503
  interactive=True,
504
  )
505
+ with gr.Row(elem_classes="halide-history-actions"):
506
+ open_history_btn = gr.Button("Open selected", size="sm")
507
+ refresh_btn = gr.Button("Refresh", size="sm")
508
  history_table = gr.Dataframe(
509
  value=initial_history_rows,
510
  headers=["Saved", "Film stock", "Defects", "Labels", "ID"],
 
514
  interactive=False,
515
  wrap=True,
516
  max_height=260,
517
+ elem_classes="halide-history-table",
518
  )
519
  history_detail = gr.HTML(
520
  value=history_detail_html(selected_entry)
521
  )
 
 
 
 
522
  with gr.Tab("JSON", id="json"):
523
  raw_output = gr.Code(
524
  value="{}",
 
536
  )
537
 
538
  run_event = run_btn.click(
539
+ fn=_running_button_state,
540
+ outputs=[run_btn, run_state_output, lighttable_empty],
541
  queue=False,
542
  )
543
  run_event.then(
 
551
  metadata_confidence,
552
  ],
553
  outputs=[
554
+ lighttable_empty,
555
  compare_output,
556
  review_gallery,
557
+ review_links_output,
558
  run_state_output,
559
  stats_output,
560
  notice_output,
 
563
  metadata_output,
564
  raw_output,
565
  defect_table,
 
566
  history_detail,
567
  history_select,
568
  history_table,
 
575
  refresh_btn.click(
576
  fn=refresh_history,
577
  inputs=[history_select],
578
+ outputs=[history_select, history_detail, raw_output, history_table],
579
+ )
580
+ open_history_btn.click(
581
+ fn=open_history,
582
+ inputs=[history_select],
583
+ outputs=[history_detail, raw_output],
584
  )
585
  history_select.change(
586
  fn=open_history,
587
  inputs=[history_select],
588
  outputs=[history_detail, raw_output],
589
  )
590
+ history_table.select(
591
+ fn=open_history_from_table,
592
+ inputs=[history_table],
593
+ outputs=[history_select, history_detail, raw_output],
594
+ )
595
 
596
  return app
597
 
ui/components.py CHANGED
@@ -11,6 +11,7 @@ import re
11
  from typing import Iterable
12
 
13
  from data.schemas import LABEL_DISPLAY_NAMES
 
14
 
15
 
16
  def _logo_html() -> str:
@@ -50,6 +51,30 @@ REPORT_EMPTY_STATE = (
50
  '<p>Results, evidence counts, and physical fixes will appear here after a GPU run.</p>'
51
  "</div>"
52
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  REPORT_SECTIONS = {
55
  "root cause": "Root cause",
@@ -89,6 +114,8 @@ def defect_table_rows(result: dict | None) -> list[list[str]]:
89
  if not result:
90
  return []
91
  defects = (result.get("defects", {}) or {}).get("defects", []) or []
 
 
92
  rows: list[list[str]] = []
93
  for index, defect in enumerate(defects, start=1):
94
  label = str(defect.get("label", ""))
@@ -148,6 +175,22 @@ def diagnosis_html(text: str) -> str:
148
  return render_markdown_report(text or "(no diagnosis produced)")
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  def render_markdown_report(text: str) -> str:
152
  """Render the constrained diagnosis Markdown used by Nemotron.
153
 
@@ -198,7 +241,7 @@ def _render_report_lines(lines: list[str]) -> str:
198
  if paragraph:
199
  blocks.append(
200
  "<p>"
201
- + " ".join(html.escape(part) for part in paragraph if part)
202
  + "</p>"
203
  )
204
  paragraph.clear()
@@ -221,15 +264,23 @@ def _render_report_lines(lines: list[str]) -> str:
221
  flush_bullets()
222
  flush_ordered()
223
  continue
 
 
 
 
 
 
 
 
224
  numbered = re.match(r"^\d+\.\s+(.*)$", line)
225
  if line.startswith("- "):
226
  flush_paragraph()
227
  flush_ordered()
228
- bullet_items.append(html.escape(line[2:].strip()))
229
  elif numbered:
230
  flush_paragraph()
231
  flush_bullets()
232
- ordered_items.append(html.escape(numbered.group(1).strip()))
233
  else:
234
  flush_bullets()
235
  flush_ordered()
@@ -241,6 +292,16 @@ def _render_report_lines(lines: list[str]) -> str:
241
  return "".join(blocks) or '<p class="halide-muted">No report text.</p>'
242
 
243
 
 
 
 
 
 
 
 
 
 
 
244
  def metadata_html(result: dict) -> str:
245
  """Render a compact metadata strip for the current run."""
246
  meta = result.get("film_metadata", {}) or {}
@@ -350,41 +411,36 @@ def history_choices(entries: Iterable[dict]) -> list[tuple[str, str]]:
350
  return [(history_label(e), str(e.get("id", ""))) for e in entries if e.get("id")]
351
 
352
 
353
- def history_row_html(entry: dict) -> str:
354
- """Render a single row in the recent-diagnoses sidebar."""
355
- counts = entry.get("label_counts", {}) or {}
356
- total = entry.get("defect_count", 0) or 0
357
- film = entry.get("film_type", "Unknown")
358
- age = entry.get("film_age_years", "?")
359
- storage = entry.get("storage", "?")
360
- ts = entry.get("created_at", 0)
361
- seconds = entry.get("total_seconds", 0.0) or 0.0
362
- stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(float(ts or 0)))
363
- return (
364
- f'<div class="halide-history-item">'
365
- f'<div class="halide-history-title">{html.escape(str(film))}</div>'
366
- f'<div class="halide-history-meta">age {html.escape(str(age))}y, '
367
- f"{html.escape(str(storage))}, {html.escape(stamp)}</div>"
368
- f"{defect_pills_html(counts)}"
369
- f'<div class="halide-history-meta">defects {int(total)} | {seconds:.2f}s</div>'
370
- f"</div>"
371
- )
372
-
373
-
374
- def render_history(entries: Iterable[dict]) -> str:
375
- items = "".join(history_row_html(e) for e in entries)
376
- if not items:
377
- return '<p class="halide-muted">No diagnoses yet.</p>'
378
- return items
379
-
380
-
381
  def history_detail_html(entry: dict | None) -> str:
382
  if not entry:
383
  return '<p class="halide-muted">Select a diagnosis to review details.</p>'
384
 
385
  counts = entry.get("label_counts", {}) or {}
386
- meta = entry.get("raw_json", {}).get("film_metadata", {}) if entry.get("raw_json") else {}
 
387
  confidence = meta.get("metadata_confidence", entry.get("metadata_confidence", "low"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  stamp = time.strftime(
389
  "%Y-%m-%d %H:%M",
390
  time.localtime(float(entry.get("created_at", 0) or 0)),
@@ -401,6 +457,7 @@ def history_detail_html(entry: dict | None) -> str:
401
  ]
402
  return (
403
  '<div class="halide-history-detail">'
 
404
  f'<div class="halide-stats compact">{"".join(header_rows)}</div>'
405
  '<div class="halide-subsection">Defects</div>'
406
  f"{defect_pills_html(counts)}"
@@ -417,12 +474,31 @@ def raw_json_text(result_or_entry: dict | None) -> str:
417
  payload = result_or_entry.get("raw_json") or {}
418
  else:
419
  payload = result_or_entry
 
420
  return json.dumps(payload, indent=2, sort_keys=True)
421
 
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  __all__ = [
424
  "HEADER_HTML",
425
  "EMPTY_STATE",
 
 
426
  "REPORT_EMPTY_STATE",
427
  "compact_label_counts",
428
  "confidence_notice_html",
@@ -435,8 +511,8 @@ __all__ = [
435
  "history_table_rows",
436
  "metadata_html",
437
  "render_markdown_report",
438
- "render_history",
439
  "run_state_html",
440
  "raw_json_text",
 
441
  "stats_html",
442
  ]
 
11
  from typing import Iterable
12
 
13
  from data.schemas import LABEL_DISPLAY_NAMES
14
+ from data.preprocessing import image_to_data_uri
15
 
16
 
17
  def _logo_html() -> str:
 
51
  '<p>Results, evidence counts, and physical fixes will appear here after a GPU run.</p>'
52
  "</div>"
53
  )
54
+ LIGHTTABLE_EMPTY_STATE = (
55
+ '<div class="halide-empty-lighttable">'
56
+ '<div class="halide-empty-frame-grid">'
57
+ '<div><span>Original</span></div>'
58
+ '<div><span>Validated overlay</span></div>'
59
+ "</div>"
60
+ '<div class="halide-empty-center">'
61
+ '<span>Ready</span>'
62
+ '<strong>No scan loaded</strong>'
63
+ "</div>"
64
+ "</div>"
65
+ )
66
+ LIGHTTABLE_RUNNING_STATE = (
67
+ '<div class="halide-empty-lighttable active">'
68
+ '<div class="halide-empty-frame-grid">'
69
+ '<div><span>Vision extraction</span></div>'
70
+ '<div><span>Diagnostic report</span></div>'
71
+ "</div>"
72
+ '<div class="halide-empty-center">'
73
+ '<span>Running</span>'
74
+ '<strong>GPU inspection in progress</strong>'
75
+ "</div>"
76
+ "</div>"
77
+ )
78
 
79
  REPORT_SECTIONS = {
80
  "root cause": "Root cause",
 
114
  if not result:
115
  return []
116
  defects = (result.get("defects", {}) or {}).get("defects", []) or []
117
+ if not defects:
118
+ return [["", "No validated defects", "", ""]]
119
  rows: list[list[str]] = []
120
  for index, defect in enumerate(defects, start=1):
121
  label = str(defect.get("label", ""))
 
175
  return render_markdown_report(text or "(no diagnosis produced)")
176
 
177
 
178
+ def review_frame_html(original, annotated) -> str:
179
+ """Render reliable full-size image links independent of Gradio fullscreen."""
180
+ original_uri = image_to_data_uri(original, max_side=1800, quality=92)
181
+ overlay_uri = image_to_data_uri(annotated, max_side=1800, quality=92)
182
+ return (
183
+ '<div class="halide-review-actions">'
184
+ '<a href="'
185
+ + original_uri
186
+ + '" target="_blank" rel="noreferrer">Open original</a>'
187
+ '<a href="'
188
+ + overlay_uri
189
+ + '" target="_blank" rel="noreferrer">Open overlay</a>'
190
+ "</div>"
191
+ )
192
+
193
+
194
  def render_markdown_report(text: str) -> str:
195
  """Render the constrained diagnosis Markdown used by Nemotron.
196
 
 
241
  if paragraph:
242
  blocks.append(
243
  "<p>"
244
+ + " ".join(_render_inline(part) for part in paragraph if part)
245
  + "</p>"
246
  )
247
  paragraph.clear()
 
264
  flush_bullets()
265
  flush_ordered()
266
  continue
267
+ if line.startswith("### "):
268
+ flush_paragraph()
269
+ flush_bullets()
270
+ flush_ordered()
271
+ blocks.append(
272
+ f'<h4 class="halide-report-subheading">{_render_inline(line[4:].strip())}</h4>'
273
+ )
274
+ continue
275
  numbered = re.match(r"^\d+\.\s+(.*)$", line)
276
  if line.startswith("- "):
277
  flush_paragraph()
278
  flush_ordered()
279
+ bullet_items.append(_render_inline(line[2:].strip()))
280
  elif numbered:
281
  flush_paragraph()
282
  flush_bullets()
283
+ ordered_items.append(_render_inline(numbered.group(1).strip()))
284
  else:
285
  flush_bullets()
286
  flush_ordered()
 
292
  return "".join(blocks) or '<p class="halide-muted">No report text.</p>'
293
 
294
 
295
+ def _render_inline(text: str) -> str:
296
+ escaped = html.escape(text)
297
+ escaped = re.sub(r"`([^`]+)`", r"<code>\1</code>", escaped)
298
+ escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
299
+ escaped = re.sub(r"__([^_]+)__", r"<strong>\1</strong>", escaped)
300
+ escaped = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", escaped)
301
+ escaped = re.sub(r"(?<!_)_([^_]+)_(?!_)", r"<em>\1</em>", escaped)
302
+ return escaped
303
+
304
+
305
  def metadata_html(result: dict) -> str:
306
  """Render a compact metadata strip for the current run."""
307
  meta = result.get("film_metadata", {}) or {}
 
411
  return [(history_label(e), str(e.get("id", ""))) for e in entries if e.get("id")]
412
 
413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  def history_detail_html(entry: dict | None) -> str:
415
  if not entry:
416
  return '<p class="halide-muted">Select a diagnosis to review details.</p>'
417
 
418
  counts = entry.get("label_counts", {}) or {}
419
+ raw = entry.get("raw_json", {}) or {}
420
+ meta = raw.get("film_metadata", {}) if raw else {}
421
  confidence = meta.get("metadata_confidence", entry.get("metadata_confidence", "low"))
422
+ preview = raw.get("preview", {}) if raw else {}
423
+ preview_html = ""
424
+ overlay_uri = str(preview.get("overlay", "") or "")
425
+ original_uri = str(preview.get("original", "") or "")
426
+ if overlay_uri.startswith("data:image/") or original_uri.startswith("data:image/"):
427
+ hero_uri = overlay_uri if overlay_uri.startswith("data:image/") else original_uri
428
+ preview_html = (
429
+ '<div class="halide-history-preview">'
430
+ f'<img src="{html.escape(hero_uri, quote=True)}" alt="" />'
431
+ '<div class="halide-history-preview-actions">'
432
+ )
433
+ if original_uri.startswith("data:image/"):
434
+ preview_html += (
435
+ f'<a href="{html.escape(original_uri, quote=True)}" '
436
+ 'target="_blank" rel="noreferrer">Original</a>'
437
+ )
438
+ if overlay_uri.startswith("data:image/"):
439
+ preview_html += (
440
+ f'<a href="{html.escape(overlay_uri, quote=True)}" '
441
+ 'target="_blank" rel="noreferrer">Overlay</a>'
442
+ )
443
+ preview_html += "</div></div>"
444
  stamp = time.strftime(
445
  "%Y-%m-%d %H:%M",
446
  time.localtime(float(entry.get("created_at", 0) or 0)),
 
457
  ]
458
  return (
459
  '<div class="halide-history-detail">'
460
+ f"{preview_html}"
461
  f'<div class="halide-stats compact">{"".join(header_rows)}</div>'
462
  '<div class="halide-subsection">Defects</div>'
463
  f"{defect_pills_html(counts)}"
 
474
  payload = result_or_entry.get("raw_json") or {}
475
  else:
476
  payload = result_or_entry
477
+ payload = _strip_preview(payload)
478
  return json.dumps(payload, indent=2, sort_keys=True)
479
 
480
 
481
+ def _strip_preview(payload: dict) -> dict:
482
+ if not isinstance(payload, dict):
483
+ return {}
484
+ clean = dict(payload)
485
+ if "preview" in clean:
486
+ preview = clean.get("preview") or {}
487
+ if isinstance(preview, dict):
488
+ clean["preview"] = {
489
+ key: "[image data URI omitted from JSON view]"
490
+ for key in preview
491
+ }
492
+ else:
493
+ clean["preview"] = "[image data URI omitted from JSON view]"
494
+ return clean
495
+
496
+
497
  __all__ = [
498
  "HEADER_HTML",
499
  "EMPTY_STATE",
500
+ "LIGHTTABLE_EMPTY_STATE",
501
+ "LIGHTTABLE_RUNNING_STATE",
502
  "REPORT_EMPTY_STATE",
503
  "compact_label_counts",
504
  "confidence_notice_html",
 
511
  "history_table_rows",
512
  "metadata_html",
513
  "render_markdown_report",
 
514
  "run_state_html",
515
  "raw_json_text",
516
+ "review_frame_html",
517
  "stats_html",
518
  ]
ui/theme.py CHANGED
@@ -266,6 +266,109 @@ body::before {{
266
  box-shadow: 0 22px 52px rgba(0, 0, 0, 0.42) !important;
267
  }}
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  .halide-section-header {{
270
  display: flex;
271
  align-items: flex-start;
@@ -316,10 +419,6 @@ body::before {{
316
  object-fit: contain !important;
317
  }}
318
 
319
- .halide-inline-controls {{
320
- gap: 8px !important;
321
- }}
322
-
323
  #halide-run-button,
324
  button.primary,
325
  .primary button {{
@@ -571,38 +670,11 @@ button {{
571
  border-color: rgba(244, 114, 182, 0.36);
572
  }}
573
 
574
- .halide-history-item {{
575
- background: rgba(33, 31, 28, 0.82);
576
- border: 1px solid rgba(58, 53, 46, 0.94);
577
- border-radius: 8px;
578
- margin-bottom: 9px;
579
- padding: 11px;
580
- }}
581
-
582
- .halide-history-title {{
583
- color: var(--halide-paper);
584
- font-weight: 860;
585
- margin-bottom: 4px;
586
- overflow-wrap: anywhere;
587
- }}
588
-
589
- .halide-history-meta {{
590
- color: var(--halide-muted);
591
- font-size: 0.78rem;
592
- line-height: 1.36;
593
- margin: 4px 0;
594
- }}
595
-
596
  .halide-history-detail {{
597
  display: grid;
598
  gap: 8px;
599
  }}
600
 
601
- .halide-history-feed {{
602
- max-height: 18rem;
603
- overflow: auto;
604
- }}
605
-
606
  .halide-intake-panel .block,
607
  .halide-inspector .block,
608
  .halide-lighttable .block {{
@@ -610,6 +682,23 @@ button {{
610
  border-color: rgba(58, 53, 46, 0.95) !important;
611
  border-radius: 8px !important;
612
  box-shadow: none !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  }}
614
 
615
  input,
@@ -621,6 +710,8 @@ select,
621
  .prose {{
622
  background-color: var(--halide-surface-soft) !important;
623
  color: var(--halide-paper) !important;
 
 
624
  }}
625
 
626
  label,
@@ -709,9 +800,190 @@ footer {{
709
  font-size: 0.82rem;
710
  }}
711
 
712
- @media (max-width: 1180px) {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  .halide-workbench {{
714
  flex-direction: column !important;
 
715
  }}
716
 
717
  .halide-intake-panel,
@@ -719,6 +991,20 @@ footer {{
719
  .halide-inspector {{
720
  width: 100% !important;
721
  }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  }}
723
 
724
  @media (max-width: 760px) {{
@@ -729,11 +1015,26 @@ footer {{
729
  #halide-header {{
730
  align-items: flex-start;
731
  flex-direction: column;
 
 
 
 
 
 
 
732
  }}
733
 
734
  .halide-model-strip {{
735
  justify-content: flex-start;
736
  min-width: 0;
 
 
 
 
 
 
 
 
737
  }}
738
 
739
  .halide-brand-mark {{
@@ -741,6 +1042,58 @@ footer {{
741
  height: 40px;
742
  }}
743
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
  .halide-stat {{
745
  grid-template-columns: 1fr;
746
  gap: 3px;
 
266
  box-shadow: 0 22px 52px rgba(0, 0, 0, 0.42) !important;
267
  }}
268
 
269
+ .halide-empty-lighttable {{
270
+ position: relative;
271
+ min-height: clamp(360px, 54vh, 760px);
272
+ overflow: hidden;
273
+ display: grid;
274
+ place-items: center;
275
+ border-radius: 7px;
276
+ border: 1px solid rgba(243, 234, 219, 0.26);
277
+ background:
278
+ linear-gradient(180deg, rgba(33, 31, 28, 0.76), rgba(5, 5, 5, 0.96)),
279
+ repeating-linear-gradient(
280
+ 0deg,
281
+ rgba(243, 234, 219, 0.035) 0,
282
+ rgba(243, 234, 219, 0.035) 1px,
283
+ transparent 1px,
284
+ transparent 24px
285
+ );
286
+ }}
287
+
288
+ .halide-empty-lighttable::after {{
289
+ content: "";
290
+ position: absolute;
291
+ inset: 24px;
292
+ border: 1px solid rgba(243, 234, 219, 0.12);
293
+ box-shadow: inset 0 0 0 1px rgba(5, 5, 5, 0.82);
294
+ pointer-events: none;
295
+ }}
296
+
297
+ .halide-empty-frame-grid {{
298
+ position: absolute;
299
+ inset: 18px;
300
+ display: grid;
301
+ grid-template-columns: repeat(2, minmax(0, 1fr));
302
+ gap: 1px;
303
+ opacity: 0.86;
304
+ }}
305
+
306
+ .halide-empty-frame-grid > div {{
307
+ position: relative;
308
+ min-width: 0;
309
+ background:
310
+ linear-gradient(135deg, rgba(17, 17, 17, 0.9), rgba(44, 41, 36, 0.46)),
311
+ repeating-linear-gradient(
312
+ 90deg,
313
+ rgba(197, 154, 82, 0.06) 0,
314
+ rgba(197, 154, 82, 0.06) 1px,
315
+ transparent 1px,
316
+ transparent 38px
317
+ );
318
+ border: 1px solid rgba(58, 53, 46, 0.92);
319
+ }}
320
+
321
+ .halide-empty-frame-grid span {{
322
+ position: absolute;
323
+ top: 12px;
324
+ left: 12px;
325
+ color: rgba(243, 234, 219, 0.72);
326
+ font-size: 0.7rem;
327
+ font-weight: 860;
328
+ letter-spacing: 0.1em !important;
329
+ text-transform: uppercase;
330
+ }}
331
+
332
+ .halide-empty-center {{
333
+ position: relative;
334
+ z-index: 2;
335
+ display: grid;
336
+ gap: 8px;
337
+ min-width: min(22rem, calc(100% - 48px));
338
+ padding: 18px 20px;
339
+ text-align: center;
340
+ border-radius: 8px;
341
+ border: 1px solid rgba(197, 154, 82, 0.42);
342
+ background: rgba(10, 10, 10, 0.78);
343
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.46);
344
+ }}
345
+
346
+ .halide-empty-center span {{
347
+ color: var(--halide-brass);
348
+ font-size: 0.68rem;
349
+ font-weight: 880;
350
+ letter-spacing: 0.12em !important;
351
+ text-transform: uppercase;
352
+ }}
353
+
354
+ .halide-empty-center strong {{
355
+ color: var(--halide-paper);
356
+ font-size: clamp(1.05rem, 1.8vw, 1.6rem);
357
+ line-height: 1.12;
358
+ }}
359
+
360
+ .halide-empty-lighttable.active {{
361
+ border-color: rgba(102, 212, 193, 0.34);
362
+ }}
363
+
364
+ .halide-empty-lighttable.active .halide-empty-center {{
365
+ border-color: rgba(102, 212, 193, 0.42);
366
+ }}
367
+
368
+ .halide-empty-lighttable.active .halide-empty-center span {{
369
+ color: var(--halide-teal);
370
+ }}
371
+
372
  .halide-section-header {{
373
  display: flex;
374
  align-items: flex-start;
 
419
  object-fit: contain !important;
420
  }}
421
 
 
 
 
 
422
  #halide-run-button,
423
  button.primary,
424
  .primary button {{
 
670
  border-color: rgba(244, 114, 182, 0.36);
671
  }}
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  .halide-history-detail {{
674
  display: grid;
675
  gap: 8px;
676
  }}
677
 
 
 
 
 
 
678
  .halide-intake-panel .block,
679
  .halide-inspector .block,
680
  .halide-lighttable .block {{
 
682
  border-color: rgba(58, 53, 46, 0.95) !important;
683
  border-radius: 8px !important;
684
  box-shadow: none !important;
685
+ outline: none !important;
686
+ overflow: hidden !important;
687
+ }}
688
+
689
+ .halide-intake-panel .block:focus-within,
690
+ .halide-inspector .block:focus-within,
691
+ .halide-lighttable .block:focus-within {{
692
+ border-color: rgba(197, 154, 82, 0.48) !important;
693
+ box-shadow: 0 0 0 1px rgba(197, 154, 82, 0.16) !important;
694
+ }}
695
+
696
+ .halide-intake-panel .form,
697
+ .halide-inspector .form,
698
+ .halide-lighttable .form {{
699
+ background: transparent !important;
700
+ border-color: transparent !important;
701
+ overflow: visible !important;
702
  }}
703
 
704
  input,
 
710
  .prose {{
711
  background-color: var(--halide-surface-soft) !important;
712
  color: var(--halide-paper) !important;
713
+ border-color: rgba(58, 53, 46, 0.95) !important;
714
+ outline: none !important;
715
  }}
716
 
717
  label,
 
800
  font-size: 0.82rem;
801
  }}
802
 
803
+ #halide-header {{
804
+ background: rgba(17, 17, 17, 0.86);
805
+ border: 1px solid rgba(197, 154, 82, 0.28);
806
+ border-top: 0;
807
+ border-radius: 0 0 8px 8px;
808
+ padding: 16px 18px 15px;
809
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
810
+ }}
811
+
812
+ .halide-intake-panel,
813
+ .halide-inspector {{
814
+ position: sticky;
815
+ top: 14px;
816
+ max-height: calc(100vh - 32px);
817
+ overflow-y: auto;
818
+ scrollbar-color: rgba(197, 154, 82, 0.48) rgba(17, 17, 17, 0.88);
819
+ }}
820
+
821
+ .halide-main-stage {{
822
+ align-self: stretch;
823
+ }}
824
+
825
+ .halide-lighttable {{
826
+ position: relative;
827
+ overflow: hidden;
828
+ padding: 16px 20px !important;
829
+ background:
830
+ linear-gradient(180deg, rgba(17, 17, 17, 0.98), rgba(5, 5, 5, 0.98)),
831
+ repeating-linear-gradient(
832
+ 90deg,
833
+ rgba(243, 234, 219, 0.026) 0,
834
+ rgba(243, 234, 219, 0.026) 1px,
835
+ transparent 1px,
836
+ transparent 28px
837
+ ) !important;
838
+ }}
839
+
840
+ .halide-lighttable::before {{
841
+ content: "";
842
+ position: absolute;
843
+ inset: 14px 8px;
844
+ pointer-events: none;
845
+ border-left: 1px solid rgba(197, 154, 82, 0.22);
846
+ border-right: 1px solid rgba(197, 154, 82, 0.22);
847
+ background:
848
+ repeating-linear-gradient(
849
+ 0deg,
850
+ rgba(197, 154, 82, 0.24) 0,
851
+ rgba(197, 154, 82, 0.24) 7px,
852
+ transparent 7px,
853
+ transparent 22px
854
+ ) left center / 5px 100% no-repeat,
855
+ repeating-linear-gradient(
856
+ 0deg,
857
+ rgba(197, 154, 82, 0.24) 0,
858
+ rgba(197, 154, 82, 0.24) 7px,
859
+ transparent 7px,
860
+ transparent 22px
861
+ ) right center / 5px 100% no-repeat;
862
+ opacity: 0.55;
863
+ }}
864
+
865
+ .halide-lighttable > * {{
866
+ position: relative;
867
+ z-index: 1;
868
+ }}
869
+
870
+ #halide-compare {{
871
+ min-height: 520px !important;
872
+ border: 1px solid rgba(243, 234, 219, 0.16) !important;
873
+ }}
874
+
875
+ .halide-review-actions {{
876
+ display: flex;
877
+ flex-wrap: wrap;
878
+ gap: 8px;
879
+ margin-top: 12px;
880
+ }}
881
+
882
+ .halide-review-actions a,
883
+ .halide-history-preview-actions a {{
884
+ display: inline-flex;
885
+ align-items: center;
886
+ justify-content: center;
887
+ min-height: 36px;
888
+ padding: 0 12px;
889
+ border-radius: 8px;
890
+ border: 1px solid rgba(102, 212, 193, 0.34);
891
+ background: rgba(102, 212, 193, 0.08);
892
+ color: #dffcf6 !important;
893
+ text-decoration: none !important;
894
+ font-size: 0.78rem;
895
+ font-weight: 820;
896
+ }}
897
+
898
+ .halide-review-actions a:hover,
899
+ .halide-history-preview-actions a:hover {{
900
+ border-color: rgba(102, 212, 193, 0.62);
901
+ background: rgba(102, 212, 193, 0.13);
902
+ }}
903
+
904
+ .halide-report-subheading {{
905
+ color: var(--halide-teal);
906
+ font-size: 0.82rem;
907
+ font-weight: 860;
908
+ margin: 10px 0 6px;
909
+ }}
910
+
911
+ .halide-report-body strong {{
912
+ color: var(--halide-paper);
913
+ }}
914
+
915
+ .halide-report-body em {{
916
+ color: var(--halide-paper-soft);
917
+ }}
918
+
919
+ .halide-report-body code {{
920
+ color: #dffcf6;
921
+ background: rgba(102, 212, 193, 0.10);
922
+ border: 1px solid rgba(102, 212, 193, 0.22);
923
+ border-radius: 5px;
924
+ padding: 1px 5px;
925
+ }}
926
+
927
+ .halide-tab-note {{
928
+ color: var(--halide-muted);
929
+ font-size: 0.8rem;
930
+ line-height: 1.35;
931
+ margin: 0 0 8px;
932
+ }}
933
+
934
+ .halide-history-actions {{
935
+ gap: 8px !important;
936
+ margin: 6px 0 8px !important;
937
+ }}
938
+
939
+ .halide-history-actions button {{
940
+ min-height: 36px !important;
941
+ }}
942
+
943
+ .halide-history-table tbody tr {{
944
+ cursor: pointer;
945
+ }}
946
+
947
+ .halide-history-table tbody tr:hover td {{
948
+ background: rgba(197, 154, 82, 0.10) !important;
949
+ }}
950
+
951
+ .halide-history-preview {{
952
+ border: 1px solid rgba(197, 154, 82, 0.26);
953
+ background: rgba(5, 5, 5, 0.74);
954
+ border-radius: 8px;
955
+ padding: 8px;
956
+ display: grid;
957
+ gap: 8px;
958
+ }}
959
+
960
+ .halide-history-preview img {{
961
+ width: 100%;
962
+ max-height: 220px;
963
+ object-fit: contain;
964
+ background: var(--halide-black);
965
+ border-radius: 6px;
966
+ }}
967
+
968
+ .halide-history-preview-actions {{
969
+ display: flex;
970
+ flex-wrap: wrap;
971
+ gap: 7px;
972
+ }}
973
+
974
+ .halide-inspector-tabs {{
975
+ min-width: 0;
976
+ }}
977
+
978
+ .halide-inspector-tabs .tab-nav,
979
+ .halide-inspector-tabs [role="tablist"] {{
980
+ overflow-x: auto;
981
+ }}
982
+
983
+ @media (max-width: 1380px) {{
984
  .halide-workbench {{
985
  flex-direction: column !important;
986
+ gap: 12px !important;
987
  }}
988
 
989
  .halide-intake-panel,
 
991
  .halide-inspector {{
992
  width: 100% !important;
993
  }}
994
+
995
+ .halide-inspector {{
996
+ min-width: 0 !important;
997
+ }}
998
+
999
+ .halide-lighttable {{
1000
+ padding: 11px !important;
1001
+ }}
1002
+
1003
+ .halide-intake-panel,
1004
+ .halide-inspector {{
1005
+ position: static;
1006
+ max-height: none;
1007
+ }}
1008
  }}
1009
 
1010
  @media (max-width: 760px) {{
 
1015
  #halide-header {{
1016
  align-items: flex-start;
1017
  flex-direction: column;
1018
+ gap: 12px;
1019
+ padding-top: 14px;
1020
+ }}
1021
+
1022
+ #halide-header h1 {{
1023
+ font-size: 1.42rem;
1024
+ max-width: 11rem;
1025
  }}
1026
 
1027
  .halide-model-strip {{
1028
  justify-content: flex-start;
1029
  min-width: 0;
1030
+ width: 100%;
1031
+ gap: 6px;
1032
+ }}
1033
+
1034
+ .halide-model-strip span,
1035
+ .halide-model-strip a {{
1036
+ padding: 7px 8px;
1037
+ font-size: 0.68rem;
1038
  }}
1039
 
1040
  .halide-brand-mark {{
 
1042
  height: 40px;
1043
  }}
1044
 
1045
+ .halide-intake-panel,
1046
+ .halide-inspector {{
1047
+ padding: 10px;
1048
+ }}
1049
+
1050
+ .halide-run-state {{
1051
+ min-height: 68px;
1052
+ padding: 12px;
1053
+ }}
1054
+
1055
+ .halide-empty-lighttable {{
1056
+ min-height: 370px;
1057
+ }}
1058
+
1059
+ .halide-empty-frame-grid {{
1060
+ inset: 10px;
1061
+ }}
1062
+
1063
+ .halide-empty-lighttable::after {{
1064
+ inset: 16px;
1065
+ }}
1066
+
1067
+ .halide-empty-frame-grid span {{
1068
+ top: 10px;
1069
+ left: 10px;
1070
+ font-size: 0.62rem;
1071
+ }}
1072
+
1073
+ .halide-empty-center {{
1074
+ min-width: calc(100% - 40px);
1075
+ padding: 15px 16px;
1076
+ }}
1077
+
1078
+ .tabs button {{
1079
+ min-height: 42px !important;
1080
+ padding: 9px 10px !important;
1081
+ }}
1082
+
1083
+ .halide-review-gallery {{
1084
+ height: 190px !important;
1085
+ }}
1086
+
1087
+ #halide-compare {{
1088
+ min-height: 360px !important;
1089
+ }}
1090
+
1091
+ #halide-run-button,
1092
+ button.primary,
1093
+ .primary button {{
1094
+ min-height: 48px !important;
1095
+ }}
1096
+
1097
  .halide-stat {{
1098
  grid-template-columns: 1fr;
1099
  gap: 3px;