Lonelyguyse1 commited on
Commit
5f76dd3
·
verified ·
1 Parent(s): c75fd99

Deploy Project Halide Gradio Space

Browse files
README.md CHANGED
@@ -35,9 +35,10 @@ Project Halide is an edge-native diagnostic workbench for analog film scans by
35
 
36
  The runtime uses MiniCPM-V 4.6 for defect extraction and
37
  Nemotron-Mini-4B-Instruct for diagnostic reasoning. The vision pass combines
38
- full-frame inspection with a tiled fallback for large scans where crack
39
- networks are too small in the global image. Model inference runs on the Space
40
- GPU runtime without cloud inference APIs.
 
41
 
42
  Fine-tuned vision model:
43
  <https://huggingface.co/Lonelyguyse1/halide-vision>
@@ -55,6 +56,41 @@ Modal was used for offline training, held-out GPU evaluation, checkpoint upload,
55
  GGUF conversion, and Space deployment. The runtime app itself does not call
56
  Modal or any hosted inference API.
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  Held-out validation summary:
59
 
60
  - Four visibly damaged private negatives were detected with scratch and
 
35
 
36
  The runtime uses MiniCPM-V 4.6 for defect extraction and
37
  Nemotron-Mini-4B-Instruct for diagnostic reasoning. The vision pass combines
38
+ full-frame inspection, tiled fallback for large scans, a conservative
39
+ image-analysis validator for obvious scratches, and geometric filtering for
40
+ sprocket or frame-edge artifacts. Model inference runs on the Space GPU runtime
41
+ without cloud inference APIs.
42
 
43
  Fine-tuned vision model:
44
  <https://huggingface.co/Lonelyguyse1/halide-vision>
 
56
  GGUF conversion, and Space deployment. The runtime app itself does not call
57
  Modal or any hosted inference API.
58
 
59
+ ## How It Works
60
+
61
+ 1. Upload a film scan, negative photo, or contact-sheet crop.
62
+ 2. MiniCPM-V 4.6 extracts candidate defects as structured JSON.
63
+ 3. The validator normalizes boxes, filters bad geometry, removes duplicate or
64
+ sprocket-like edge artifacts, and adds high-precision scratch candidates
65
+ when clear linear evidence is visible.
66
+ 4. Nemotron-Mini-4B-Instruct reads the validated evidence plus user metadata and
67
+ writes a lab-style diagnosis with physical fixes.
68
+ 5. SQLite stores local diagnostic history so earlier runs can be reopened.
69
+
70
+ ## Sponsor Usage
71
+
72
+ - OpenBMB: MiniCPM-V 4.6 is the primary vision model, fine-tuned for film defect
73
+ extraction and published at `Lonelyguyse1/halide-vision`.
74
+ - NVIDIA: Nemotron-Mini-4B-Instruct produces the diagnostic report and keeps
75
+ uncertain film metadata lower priority than visible evidence.
76
+ - Modal: used offline for training, evaluation, checkpoint export, GGUF
77
+ conversion, model upload, and Space deployment support.
78
+ - OpenAI Codex: used for implementation, testing, documentation, and
79
+ source-control commits in the linked GitHub repository.
80
+
81
+ ## Field Guide Alignment
82
+
83
+ - Gradio Space under the official `build-small-hackathon` organization.
84
+ - All runtime inference uses open weights on the Space GPU, with no hosted model
85
+ API calls.
86
+ - Model sizes stay under the 32B limit, with MiniCPM-V 4.6 at 1.3B parameters
87
+ and Nemotron-Mini-4B-Instruct at 4B parameters.
88
+ - Custom autumn-themed UI with a purpose-built compare viewer and diagnostic
89
+ history.
90
+ - Fine-tuned vision model and GGUF artifact are published on the author's
91
+ Hugging Face profile.
92
+ - Demo video, public launch post, and field notes are linked from this Space.
93
+
94
  Held-out validation summary:
95
 
96
  - Four visibly damaged private negatives were detected with scratch and
config.py CHANGED
@@ -69,6 +69,8 @@ class VisionConfig:
69
  tile_max_side: int
70
  tile_overlap: float
71
  tile_max_tiles: int
 
 
72
 
73
 
74
  @dataclass(frozen=True)
@@ -109,6 +111,8 @@ def get_vision_config() -> VisionConfig:
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
 
 
69
  tile_max_side: int
70
  tile_overlap: float
71
  tile_max_tiles: int
72
+ classical_assist_enabled: bool
73
+ classical_assist_max_defects: int
74
 
75
 
76
  @dataclass(frozen=True)
 
111
  tile_max_side=env_int("HALIDE_TILE_MAX_SIDE", 960),
112
  tile_overlap=env_float("HALIDE_TILE_OVERLAP", 0.35),
113
  tile_max_tiles=env_int("HALIDE_TILE_MAX_TILES", 9),
114
+ classical_assist_enabled=env_bool("HALIDE_ENABLE_CLASSICAL_ASSIST", True),
115
+ classical_assist_max_defects=env_int("HALIDE_CLASSICAL_ASSIST_MAX_DEFECTS", 32),
116
  )
117
 
118
 
data/schemas.py CHANGED
@@ -269,6 +269,57 @@ def dedupe_defects(
269
  return merged, duplicate_count
270
 
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  def bbox_area(bbox: Any) -> float:
273
  norm = normalize_bbox(bbox)
274
  if norm is None:
@@ -361,6 +412,7 @@ __all__ = [
361
  "bbox_to_pixels",
362
  "clean_defects",
363
  "dedupe_defects",
 
364
  "label_counts",
365
  "normalize_bbox",
366
  "spatial_summary",
 
269
  return merged, duplicate_count
270
 
271
 
272
+ def filter_edge_artifacts(defects: Iterable[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
273
+ """Drop repeated edge artifacts that look like film borders or sprockets."""
274
+ filtered: list[dict[str, Any]] = []
275
+ dropped = 0
276
+ for defect in defects:
277
+ label = str(defect.get("label", ""))
278
+ bbox = normalize_bbox(defect.get("bbox"))
279
+ if bbox is None:
280
+ dropped += 1
281
+ continue
282
+ if _is_edge_artifact(label, bbox, defect.get("confidence")):
283
+ dropped += 1
284
+ continue
285
+ filtered.append(_serialize_defect(label, bbox, defect))
286
+ return filtered, dropped
287
+
288
+
289
+ def _is_edge_artifact(label: str, bbox: BBox, confidence: Any) -> bool:
290
+ x_min, y_min, x_max, y_max = bbox
291
+ width = x_max - x_min
292
+ height = y_max - y_min
293
+ area = width * height
294
+ center_x = (x_min + x_max) / 2.0
295
+
296
+ try:
297
+ confidence_value = float(confidence) if confidence is not None else None
298
+ except (TypeError, ValueError):
299
+ confidence_value = None
300
+
301
+ low_evidence = confidence_value is None or confidence_value < 0.62
302
+ if not low_evidence:
303
+ return False
304
+
305
+ if label == "dust" and area < 0.0016 and (center_x < 0.12 or center_x > 0.88):
306
+ return True
307
+
308
+ if width < 0.02 and height > 0.18 and (x_min <= 0.004 or x_max >= 0.996):
309
+ return True
310
+
311
+ if height < 0.02 and width > 0.22 and (y_min <= 0.004 or y_max >= 0.996):
312
+ return True
313
+
314
+ if label in {"scratch", "emulsion_damage"}:
315
+ if width < 0.075 and height > 0.22 and (x_min <= 0.006 or x_max >= 0.994):
316
+ return True
317
+ if height < 0.075 and width > 0.22 and (y_min <= 0.006 or y_max >= 0.994):
318
+ return True
319
+
320
+ return False
321
+
322
+
323
  def bbox_area(bbox: Any) -> float:
324
  norm = normalize_bbox(bbox)
325
  if norm is None:
 
412
  "bbox_to_pixels",
413
  "clean_defects",
414
  "dedupe_defects",
415
+ "filter_edge_artifacts",
416
  "label_counts",
417
  "normalize_bbox",
418
  "spatial_summary",
models/vision/classical_assist.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative image-analysis assist for obvious film defects.
2
+
3
+ This module does not run a model. It uses local contrast only to catch clear
4
+ linear scratches and compact bright debris that MiniCPM can miss on real scans.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from PIL import Image, ImageFilter, ImageOps
14
+
15
+ from data.schemas import bbox_area, bbox_iou, normalize_bbox
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Candidate:
20
+ label: str
21
+ bbox: tuple[float, float, float, float]
22
+ score: float
23
+
24
+ def to_json(self) -> dict[str, Any]:
25
+ confidence = min(0.82, max(0.52, 0.5 + self.score * 0.72))
26
+ return {
27
+ "label": self.label,
28
+ "bbox": [round(v, 6) for v in self.bbox],
29
+ "confidence": round(confidence, 4),
30
+ }
31
+
32
+
33
+ def detect_classical_defects(
34
+ image: Any,
35
+ *,
36
+ max_defects: int = 32,
37
+ include_compact_debris: bool = False,
38
+ ) -> list[dict[str, Any]]:
39
+ """Return high-precision defect candidates from image structure only."""
40
+ pil_image = ImageOps.exif_transpose(image.convert("RGB"))
41
+ width, height = pil_image.size
42
+ if width < 64 or height < 64:
43
+ return []
44
+
45
+ work = _resize_work_image(pil_image)
46
+ gray = _gray_array(work)
47
+ blur = _blur_array(gray, work.size)
48
+ residual = gray - blur
49
+ mask = _bright_residual_mask(gray, residual)
50
+
51
+ candidates: list[Candidate] = []
52
+ candidates.extend(_linear_candidates(mask, residual))
53
+ if include_compact_debris:
54
+ candidates.extend(_compact_debris_candidates(mask, residual))
55
+
56
+ candidates = _dedupe_candidates(candidates, max_defects=max(1, int(max_defects)))
57
+ return [candidate.to_json() for candidate in candidates]
58
+
59
+
60
+ def _resize_work_image(image: Image.Image) -> Image.Image:
61
+ width, height = image.size
62
+ max_side = 1100
63
+ scale = min(1.0, max_side / float(max(width, height)))
64
+ if scale >= 1.0:
65
+ return image.copy()
66
+ new_size = (
67
+ max(1, int(round(width * scale))),
68
+ max(1, int(round(height * scale))),
69
+ )
70
+ return image.resize(new_size, Image.Resampling.LANCZOS)
71
+
72
+
73
+ def _gray_array(image: Image.Image) -> np.ndarray:
74
+ arr = np.asarray(image).astype("float32") / 255.0
75
+ return (
76
+ 0.2126 * arr[:, :, 0]
77
+ + 0.7152 * arr[:, :, 1]
78
+ + 0.0722 * arr[:, :, 2]
79
+ )
80
+
81
+
82
+ def _blur_array(gray: np.ndarray, size: tuple[int, int]) -> np.ndarray:
83
+ width, height = size
84
+ radius = max(2, int(round(max(width, height) * 0.006)))
85
+ source = Image.fromarray(np.uint8(np.clip(gray * 255.0, 0, 255)))
86
+ blurred = source.filter(ImageFilter.GaussianBlur(radius=radius))
87
+ return np.asarray(blurred).astype("float32") / 255.0
88
+
89
+
90
+ def _bright_residual_mask(gray: np.ndarray, residual: np.ndarray) -> np.ndarray:
91
+ threshold = max(0.045, float(np.percentile(residual, 99.2)))
92
+ mask = (residual >= threshold) & (gray > float(np.percentile(gray, 42)))
93
+ mask[:2, :] = False
94
+ mask[-2:, :] = False
95
+ mask[:, :2] = False
96
+ mask[:, -2:] = False
97
+ return mask
98
+
99
+
100
+ def _linear_candidates(mask: np.ndarray, residual: np.ndarray) -> list[Candidate]:
101
+ height, width = mask.shape
102
+ min_horizontal = max(22, int(round(width * 0.035)))
103
+ min_vertical = max(22, int(round(height * 0.035)))
104
+ candidates: list[Candidate] = []
105
+
106
+ for y in range(height):
107
+ xs = np.flatnonzero(mask[y])
108
+ if xs.size == 0:
109
+ continue
110
+ for run in _contiguous_runs(xs):
111
+ if run.size < min_horizontal:
112
+ continue
113
+ x0, x1 = int(run[0]), int(run[-1]) + 1
114
+ pad = max(2, int(round(height * 0.003)))
115
+ bbox = (
116
+ x0 / width,
117
+ max(0, y - pad) / height,
118
+ x1 / width,
119
+ min(height, y + pad + 1) / height,
120
+ )
121
+ if _is_border_frame(bbox):
122
+ continue
123
+ candidates.append(
124
+ Candidate("scratch", bbox, float(residual[y, run].mean()))
125
+ )
126
+
127
+ for x in range(width):
128
+ ys = np.flatnonzero(mask[:, x])
129
+ if ys.size == 0:
130
+ continue
131
+ for run in _contiguous_runs(ys):
132
+ if run.size < min_vertical:
133
+ continue
134
+ y0, y1 = int(run[0]), int(run[-1]) + 1
135
+ pad = max(2, int(round(width * 0.003)))
136
+ bbox = (
137
+ max(0, x - pad) / width,
138
+ y0 / height,
139
+ min(width, x + pad + 1) / width,
140
+ y1 / height,
141
+ )
142
+ if _is_border_frame(bbox):
143
+ continue
144
+ candidates.append(
145
+ Candidate("scratch", bbox, float(residual[run, x].mean()))
146
+ )
147
+
148
+ return candidates
149
+
150
+
151
+ def _compact_debris_candidates(mask: np.ndarray, residual: np.ndarray) -> list[Candidate]:
152
+ if int(mask.sum()) > 45_000:
153
+ return []
154
+
155
+ height, width = mask.shape
156
+ visited = np.zeros(mask.shape, dtype=bool)
157
+ points = np.argwhere(mask)
158
+ candidates: list[Candidate] = []
159
+
160
+ for y_raw, x_raw in points:
161
+ y = int(y_raw)
162
+ x = int(x_raw)
163
+ if visited[y, x] or not mask[y, x]:
164
+ continue
165
+ coords = _component(mask, visited, y, x, max_pixels=900)
166
+ if len(coords) < 3 or len(coords) > 850:
167
+ continue
168
+ ys = np.array([coord[0] for coord in coords], dtype=np.int32)
169
+ xs = np.array([coord[1] for coord in coords], dtype=np.int32)
170
+ x0 = int(xs.min())
171
+ x1 = int(xs.max()) + 1
172
+ y0 = int(ys.min())
173
+ y1 = int(ys.max()) + 1
174
+ box_width = x1 - x0
175
+ box_height = y1 - y0
176
+ if box_width > width * 0.1 or box_height > height * 0.1:
177
+ continue
178
+
179
+ aspect = max(box_width / max(1, box_height), box_height / max(1, box_width))
180
+ label = "scratch" if aspect >= 8.0 else "dust"
181
+ pad = 3 if label == "scratch" else 2
182
+ bbox = (
183
+ max(0, x0 - pad) / width,
184
+ max(0, y0 - pad) / height,
185
+ min(width, x1 + pad) / width,
186
+ min(height, y1 + pad) / height,
187
+ )
188
+ if _is_tiny_edge_artifact(bbox) or _is_border_frame(bbox):
189
+ continue
190
+ score = float(residual[ys, xs].mean())
191
+ candidates.append(Candidate(label, bbox, score))
192
+
193
+ return candidates
194
+
195
+
196
+ def _component(
197
+ mask: np.ndarray,
198
+ visited: np.ndarray,
199
+ start_y: int,
200
+ start_x: int,
201
+ *,
202
+ max_pixels: int,
203
+ ) -> list[tuple[int, int]]:
204
+ height, width = mask.shape
205
+ stack = [(start_y, start_x)]
206
+ visited[start_y, start_x] = True
207
+ coords: list[tuple[int, int]] = []
208
+ while stack and len(coords) < max_pixels:
209
+ y, x = stack.pop()
210
+ coords.append((y, x))
211
+ for next_y in (y - 1, y, y + 1):
212
+ if next_y < 0 or next_y >= height:
213
+ continue
214
+ for next_x in (x - 1, x, x + 1):
215
+ if next_x < 0 or next_x >= width:
216
+ continue
217
+ if visited[next_y, next_x] or not mask[next_y, next_x]:
218
+ continue
219
+ visited[next_y, next_x] = True
220
+ stack.append((next_y, next_x))
221
+ return coords
222
+
223
+
224
+ def _contiguous_runs(values: np.ndarray) -> list[np.ndarray]:
225
+ splits = np.where(np.diff(values) > 1)[0] + 1
226
+ return list(np.split(values, splits))
227
+
228
+
229
+ def _dedupe_candidates(
230
+ candidates: list[Candidate],
231
+ *,
232
+ max_defects: int,
233
+ ) -> list[Candidate]:
234
+ kept: list[Candidate] = []
235
+ for candidate in sorted(candidates, key=lambda item: item.score, reverse=True):
236
+ if len(kept) >= max_defects:
237
+ break
238
+ bbox = normalize_bbox(candidate.bbox)
239
+ if bbox is None or bbox_area(bbox) <= 0:
240
+ continue
241
+ if any(_overlaps_existing(candidate, existing) for existing in kept):
242
+ continue
243
+ kept.append(candidate)
244
+ return kept
245
+
246
+
247
+ def _overlaps_existing(candidate: Candidate, existing: Candidate) -> bool:
248
+ if bbox_iou(candidate.bbox, existing.bbox) >= 0.15:
249
+ return True
250
+ if candidate.label != "scratch" or existing.label != "scratch":
251
+ return False
252
+ cx0, cy0, cx1, cy1 = candidate.bbox
253
+ ex0, ey0, ex1, ey1 = existing.bbox
254
+ same_row = abs(((cy0 + cy1) / 2.0) - ((ey0 + ey1) / 2.0)) < 0.025
255
+ x_overlap = max(0.0, min(cx1, ex1) - max(cx0, ex0))
256
+ return same_row and x_overlap > 0.05
257
+
258
+
259
+ def _is_border_frame(bbox: tuple[float, float, float, float]) -> bool:
260
+ x0, y0, x1, y1 = bbox
261
+ width = x1 - x0
262
+ height = y1 - y0
263
+ near_outer_edge = x0 < 0.012 or y0 < 0.012 or x1 > 0.988 or y1 > 0.988
264
+ return near_outer_edge and (width > 0.2 or height > 0.2)
265
+
266
+
267
+ def _is_tiny_edge_artifact(bbox: tuple[float, float, float, float]) -> bool:
268
+ x0, y0, x1, y1 = bbox
269
+ area = max(0.0, x1 - x0) * max(0.0, y1 - y0)
270
+ center_x = (x0 + x1) / 2.0
271
+ return area < 0.0016 and (center_x < 0.12 or center_x > 0.88)
272
+
273
+
274
+ __all__ = ["detect_classical_defects"]
models/vision/inference.py CHANGED
@@ -7,8 +7,16 @@ from pathlib import Path
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
 
14
  logger = logging.getLogger(__name__)
@@ -31,6 +39,9 @@ def extract_defects(image: Any) -> dict:
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):
@@ -60,6 +71,24 @@ def extract_defects(image: Any) -> dict:
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
@@ -70,6 +99,7 @@ def extract_defects(image: Any) -> dict:
70
  "label_counts": counts,
71
  "dropped_count": dropped,
72
  "duplicate_count": duplicate_count,
 
73
  "inference_seconds": round(elapsed, 3),
74
  "model_path": detector.model_path,
75
  "parse_error": raw.get("_parse_error"),
@@ -78,6 +108,8 @@ def extract_defects(image: Any) -> dict:
78
  "tile_count": tile_count,
79
  "full_frame_defect_count": full_frame_count,
80
  "tile_parse_errors": tile_parse_errors,
 
 
81
  }
82
 
83
 
 
7
  from typing import Any
8
 
9
  from config import get_vision_config
10
+ from data.schemas import (
11
+ BBox,
12
+ clean_defects,
13
+ dedupe_defects,
14
+ filter_edge_artifacts,
15
+ label_counts,
16
+ normalize_bbox,
17
+ )
18
  from data.preprocessing import load_image
19
+ from models.vision.classical_assist import detect_classical_defects
20
  from models.vision.minicpm_wrapper import get_detector
21
 
22
  logger = logging.getLogger(__name__)
 
39
  tile_fallback_used = False
40
  tile_count = 0
41
  tile_parse_errors: list[str] = []
42
+ classical_assist_count = 0
43
+ classical_assist_used = False
44
+ edge_artifact_count = 0
45
 
46
  cfg = get_vision_config()
47
  if _should_run_tile_fallback(input_image, cleaned):
 
71
  break
72
  cleaned = tile_defects
73
 
74
+ if cfg.classical_assist_enabled:
75
+ include_compact_debris = len(cleaned) == 0
76
+ classical_raw = detect_classical_defects(
77
+ input_image,
78
+ max_defects=cfg.classical_assist_max_defects,
79
+ include_compact_debris=include_compact_debris,
80
+ )
81
+ classical_cleaned, classical_dropped = clean_defects(classical_raw)
82
+ dropped += classical_dropped
83
+ if cleaned:
84
+ classical_cleaned = [
85
+ defect for defect in classical_cleaned if defect.get("label") == "scratch"
86
+ ]
87
+ classical_assist_count = len(classical_cleaned)
88
+ classical_assist_used = bool(classical_cleaned)
89
+ cleaned.extend(classical_cleaned)
90
+
91
+ cleaned, edge_artifact_count = filter_edge_artifacts(cleaned)
92
  cleaned, duplicate_count = dedupe_defects(cleaned)
93
  counts = label_counts(cleaned)
94
  elapsed = time.perf_counter() - started
 
99
  "label_counts": counts,
100
  "dropped_count": dropped,
101
  "duplicate_count": duplicate_count,
102
+ "edge_artifact_count": edge_artifact_count,
103
  "inference_seconds": round(elapsed, 3),
104
  "model_path": detector.model_path,
105
  "parse_error": raw.get("_parse_error"),
 
108
  "tile_count": tile_count,
109
  "full_frame_defect_count": full_frame_count,
110
  "tile_parse_errors": tile_parse_errors,
111
+ "classical_assist_used": classical_assist_used,
112
+ "classical_assist_count": classical_assist_count,
113
  }
114
 
115
 
ui/app.py CHANGED
@@ -24,6 +24,7 @@ from ui.components import (
24
  LIGHTTABLE_EMPTY_STATE,
25
  LIGHTTABLE_RUNNING_STATE,
26
  REPORT_EMPTY_STATE,
 
27
  confidence_notice_html,
28
  defect_table_rows,
29
  defect_pills_html,
@@ -245,11 +246,7 @@ def run_pipeline(
245
 
246
  counts = result.get("defects", {}).get("label_counts", {}) or {}
247
  defects = result.get("defects", {}).get("defects", []) or []
248
- annotated = draw_defects(
249
- pil_image,
250
- defects,
251
- title=f"Halide: {len(defects)} validated defects",
252
- )
253
  result = _attach_preview(result, pil_image, annotated)
254
  if not was_cached:
255
  try:
@@ -261,8 +258,10 @@ def run_pipeline(
261
  elif not result.get("diagnosis_id"):
262
  cache.put(image_bytes, result, metadata=metadata)
263
 
264
- image_pair = (pil_image, annotated)
265
- compare = gr.update(value=image_pair, visible=True)
 
 
266
  gallery = gr.update(value=_review_gallery(pil_image, annotated), visible=True)
267
  review_links = gr.update(
268
  value=review_frame_html(pil_image, annotated),
@@ -446,15 +445,8 @@ def build_app() -> gr.Blocks:
446
  "</div>"
447
  )
448
  lighttable_empty = gr.HTML(value=LIGHTTABLE_EMPTY_STATE)
449
- compare_output = gr.ImageSlider(
450
- value=None,
451
- label="Original / overlay",
452
- type="pil",
453
- height=620,
454
- max_height=680,
455
- slider_position=52,
456
- interactive=False,
457
- buttons=["download", "fullscreen"],
458
  elem_id="halide-compare",
459
  visible=False,
460
  )
 
24
  LIGHTTABLE_EMPTY_STATE,
25
  LIGHTTABLE_RUNNING_STATE,
26
  REPORT_EMPTY_STATE,
27
+ comparison_viewer_html,
28
  confidence_notice_html,
29
  defect_table_rows,
30
  defect_pills_html,
 
246
 
247
  counts = result.get("defects", {}).get("label_counts", {}) or {}
248
  defects = result.get("defects", {}).get("defects", []) or []
249
+ annotated = draw_defects(pil_image, defects)
 
 
 
 
250
  result = _attach_preview(result, pil_image, annotated)
251
  if not was_cached:
252
  try:
 
258
  elif not result.get("diagnosis_id"):
259
  cache.put(image_bytes, result, metadata=metadata)
260
 
261
+ compare = gr.update(
262
+ value=comparison_viewer_html(pil_image, annotated),
263
+ visible=True,
264
+ )
265
  gallery = gr.update(value=_review_gallery(pil_image, annotated), visible=True)
266
  review_links = gr.update(
267
  value=review_frame_html(pil_image, annotated),
 
445
  "</div>"
446
  )
447
  lighttable_empty = gr.HTML(value=LIGHTTABLE_EMPTY_STATE)
448
+ compare_output = gr.HTML(
449
+ value="",
 
 
 
 
 
 
 
450
  elem_id="halide-compare",
451
  visible=False,
452
  )
ui/components.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  import base64
 
6
  import html
7
  import json
8
  import time
@@ -14,6 +15,7 @@ from data.schemas import LABEL_DISPLAY_NAMES
14
  from data.preprocessing import image_to_data_uri
15
 
16
 
 
17
  def _logo_html() -> str:
18
  path = Path(__file__).resolve().parents[1] / "assets" / "logo.jpg"
19
  if not path.exists():
@@ -25,6 +27,9 @@ def _logo_html() -> str:
25
  )
26
 
27
 
 
 
 
28
  HEADER_HTML = f"""
29
  <div id="halide-header">
30
  <div class="halide-brand-lockup">
@@ -126,7 +131,9 @@ def defect_table_rows(result: dict | None) -> list[list[str]]:
126
  else:
127
  box_text = "invalid"
128
  confidence = defect.get("confidence")
129
- confidence_text = "" if confidence is None else f"{float(confidence):.2f}"
 
 
130
  rows.append([str(index), display, confidence_text, box_text])
131
  return rows
132
 
@@ -143,6 +150,8 @@ def stats_html(result: dict) -> str:
143
  rows.append(_stat_row("Total defects", str(defects.get("defect_count", 0))))
144
  rows.append(_stat_row("Dropped (invalid)", str(defects.get("dropped_count", 0))))
145
  rows.append(_stat_row("Duplicates removed", str(defects.get("duplicate_count", 0))))
 
 
146
  rows.append(_stat_row("Resized for model", "yes" if defects.get("resized_for_model") else "no"))
147
  rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
148
  rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
@@ -191,6 +200,33 @@ def review_frame_html(original, annotated) -> str:
191
  )
192
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  def render_markdown_report(text: str) -> str:
195
  """Render the constrained diagnosis Markdown used by Nemotron.
196
 
@@ -327,10 +363,21 @@ def confidence_notice_html(result: dict) -> str:
327
  duplicate = int(defects.get("duplicate_count", 0) or 0)
328
  meta = result.get("film_metadata", {}) or {}
329
  confidence = str(meta.get("metadata_confidence", "low") or "low").lower()
 
 
 
 
 
330
 
331
  if total == 0:
332
  message = "No validated boxes were returned. Inspect the scan before assuming a film fault."
333
  tone = "neutral"
 
 
 
 
 
 
334
  elif confidence == "low":
335
  message = "Metadata is marked low confidence, so the diagnosis is driven mainly by visible defects."
336
  tone = "caution"
@@ -501,6 +548,7 @@ __all__ = [
501
  "LIGHTTABLE_RUNNING_STATE",
502
  "REPORT_EMPTY_STATE",
503
  "compact_label_counts",
 
504
  "confidence_notice_html",
505
  "defect_table_rows",
506
  "defect_pills_html",
 
3
  from __future__ import annotations
4
 
5
  import base64
6
+ import functools
7
  import html
8
  import json
9
  import time
 
15
  from data.preprocessing import image_to_data_uri
16
 
17
 
18
+ @functools.lru_cache(maxsize=1)
19
  def _logo_html() -> str:
20
  path = Path(__file__).resolve().parents[1] / "assets" / "logo.jpg"
21
  if not path.exists():
 
27
  )
28
 
29
 
30
+ COMPARE_DEFAULT_SPLIT = 50
31
+
32
+
33
  HEADER_HTML = f"""
34
  <div id="halide-header">
35
  <div class="halide-brand-lockup">
 
131
  else:
132
  box_text = "invalid"
133
  confidence = defect.get("confidence")
134
+ confidence_text = (
135
+ "not emitted" if confidence is None else f"{float(confidence):.2f}"
136
+ )
137
  rows.append([str(index), display, confidence_text, box_text])
138
  return rows
139
 
 
150
  rows.append(_stat_row("Total defects", str(defects.get("defect_count", 0))))
151
  rows.append(_stat_row("Dropped (invalid)", str(defects.get("dropped_count", 0))))
152
  rows.append(_stat_row("Duplicates removed", str(defects.get("duplicate_count", 0))))
153
+ rows.append(_stat_row("Edge artifacts removed", str(defects.get("edge_artifact_count", 0))))
154
+ rows.append(_stat_row("CV assist boxes", str(defects.get("classical_assist_count", 0))))
155
  rows.append(_stat_row("Resized for model", "yes" if defects.get("resized_for_model") else "no"))
156
  rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
157
  rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
 
200
  )
201
 
202
 
203
+ def comparison_viewer_html(original, annotated) -> str:
204
+ """Render an aligned before/after viewer using the same image canvas."""
205
+ original_uri = image_to_data_uri(original, max_side=1800, quality=92)
206
+ overlay_uri = image_to_data_uri(annotated, max_side=1800, quality=92)
207
+ return (
208
+ '<div class="halide-compare-viewer" '
209
+ f'style="--halide-split: {COMPARE_DEFAULT_SPLIT}%;">'
210
+ '<div class="halide-compare-stage">'
211
+ f'<img class="halide-compare-base" src="{original_uri}" alt="" />'
212
+ '<div class="halide-compare-overlay">'
213
+ f'<img src="{overlay_uri}" alt="" />'
214
+ "</div>"
215
+ '<div class="halide-compare-divider"></div>'
216
+ '<span class="halide-compare-label original">Original</span>'
217
+ '<span class="halide-compare-label overlay">Validated overlay</span>'
218
+ "</div>"
219
+ '<input class="halide-compare-range" type="range" min="0" max="100" '
220
+ f'value="{COMPARE_DEFAULT_SPLIT}" '
221
+ 'aria-label="Compare original and validated overlay" '
222
+ 'oninput="const viewer=this.closest('
223
+ "'.halide-compare-viewer'); "
224
+ "if (viewer) { viewer.style.setProperty('--halide-split', this.value + '%'); }"
225
+ '" />'
226
+ "</div>"
227
+ )
228
+
229
+
230
  def render_markdown_report(text: str) -> str:
231
  """Render the constrained diagnosis Markdown used by Nemotron.
232
 
 
363
  duplicate = int(defects.get("duplicate_count", 0) or 0)
364
  meta = result.get("film_metadata", {}) or {}
365
  confidence = str(meta.get("metadata_confidence", "low") or "low").lower()
366
+ confidence_values = [
367
+ defect.get("confidence")
368
+ for defect in defects.get("defects", []) or []
369
+ if defect.get("confidence") is not None
370
+ ]
371
 
372
  if total == 0:
373
  message = "No validated boxes were returned. Inspect the scan before assuming a film fault."
374
  tone = "neutral"
375
+ elif not confidence_values:
376
+ message = (
377
+ "Defect boxes passed schema validation. MiniCPM did not emit numeric "
378
+ "per-box confidence for this run."
379
+ )
380
+ tone = "good"
381
  elif confidence == "low":
382
  message = "Metadata is marked low confidence, so the diagnosis is driven mainly by visible defects."
383
  tone = "caution"
 
548
  "LIGHTTABLE_RUNNING_STATE",
549
  "REPORT_EMPTY_STATE",
550
  "compact_label_counts",
551
+ "comparison_viewer_html",
552
  "confidence_notice_html",
553
  "defect_table_rows",
554
  "defect_pills_html",
ui/theme.py CHANGED
@@ -4,23 +4,23 @@ from __future__ import annotations
4
 
5
  import gradio as gr
6
 
7
- BRASS = "#c59a52"
8
- BRASS_DARK = "#8a6431"
9
- COPPER = "#b85f3f"
10
- TEAL = "#66d4c1"
11
- VIOLET = "#9d8cff"
12
- RED = "#ef5d52"
13
-
14
- INK = "#0a0a0a"
15
- CARBON = "#111111"
16
- SURFACE = "#181715"
17
- SURFACE_SOFT = "#211f1c"
18
- SURFACE_LIFT = "#2c2924"
19
- PAPER = "#f3eadb"
20
- PAPER_SOFT = "#d7cbb8"
21
- MUTED = "#a99b88"
22
- BORDER = "#3a352e"
23
- BLACK = "#050505"
24
 
25
  THEME_CSS = f"""
26
  :root {{
@@ -60,8 +60,8 @@ body::before {{
60
  background:
61
  repeating-linear-gradient(
62
  90deg,
63
- rgba(255, 255, 255, 0.018) 0,
64
- rgba(255, 255, 255, 0.018) 1px,
65
  transparent 1px,
66
  transparent 16px
67
  );
@@ -127,7 +127,7 @@ body::before {{
127
  display: flex;
128
  flex-wrap: wrap;
129
  justify-content: flex-end;
130
- gap: 8px;
131
  min-width: min(42vw, 36rem);
132
  }}
133
 
@@ -137,17 +137,18 @@ body::before {{
137
  color: var(--halide-paper);
138
  background: rgba(24, 23, 21, 0.92);
139
  border-radius: 8px;
140
- padding: 8px 10px;
141
  font-size: 0.76rem;
142
  font-weight: 780;
143
- line-height: 1;
144
  text-decoration: none;
145
  white-space: nowrap;
146
  }}
147
 
148
  .halide-model-strip a {{
149
- color: #dffcf6;
150
- border-color: rgba(102, 212, 193, 0.38);
 
151
  }}
152
 
153
  .halide-workbench {{
@@ -188,8 +189,8 @@ body::before {{
188
  }}
189
 
190
  .halide-model-card {{
191
- border: 1px solid rgba(102, 212, 193, 0.28);
192
- background: rgba(102, 212, 193, 0.055);
193
  border-radius: 8px;
194
  padding: 12px;
195
  margin-top: 12px;
@@ -243,11 +244,11 @@ body::before {{
243
  }}
244
 
245
  .halide-run-state.active {{
246
- border-color: rgba(102, 212, 193, 0.46);
247
  }}
248
 
249
  .halide-run-state.quiet {{
250
- border-color: rgba(157, 140, 255, 0.35);
251
  }}
252
 
253
  .halide-run-eyebrow {{
@@ -259,7 +260,7 @@ body::before {{
259
  }}
260
 
261
  .halide-lighttable {{
262
- background: #0f0f0e !important;
263
  border: 1px solid rgba(197, 154, 82, 0.34) !important;
264
  border-radius: 8px !important;
265
  padding: 13px !important;
@@ -358,11 +359,11 @@ body::before {{
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 {{
@@ -403,7 +404,6 @@ body::before {{
403
  line-height: 1;
404
  }}
405
 
406
- #halide-compare,
407
  .halide-review-gallery {{
408
  background: var(--halide-black) !important;
409
  }}
@@ -413,12 +413,95 @@ body::before {{
413
  }}
414
 
415
  .halide-upload img,
416
- #halide-compare img,
417
  .halide-review-gallery img {{
418
  background: var(--halide-black) !important;
419
  object-fit: contain !important;
420
  }}
421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  #halide-run-button,
423
  button.primary,
424
  .primary button {{
@@ -511,8 +594,8 @@ button {{
511
  }}
512
 
513
  .halide-notice.good {{
514
- border-color: rgba(102, 212, 193, 0.38);
515
- background: rgba(102, 212, 193, 0.075);
516
  }}
517
 
518
  .halide-notice.caution {{
@@ -521,8 +604,8 @@ button {{
521
  }}
522
 
523
  .halide-notice.neutral {{
524
- border-color: rgba(157, 140, 255, 0.36);
525
- background: rgba(157, 140, 255, 0.075);
526
  }}
527
 
528
  .halide-stats {{
@@ -646,13 +729,13 @@ button {{
646
  }}
647
 
648
  .halide-defect-pill.long_hair {{
649
- background: rgba(157, 140, 255, 0.13);
650
- border-color: rgba(157, 140, 255, 0.38);
651
  }}
652
 
653
  .halide-defect-pill.short_hair {{
654
- background: rgba(102, 212, 193, 0.11);
655
- border-color: rgba(102, 212, 193, 0.36);
656
  }}
657
 
658
  .halide-defect-pill.emulsion_damage {{
@@ -661,8 +744,8 @@ button {{
661
  }}
662
 
663
  .halide-defect-pill.chemical_stain {{
664
- background: rgba(74, 222, 128, 0.12);
665
- border-color: rgba(74, 222, 128, 0.34);
666
  }}
667
 
668
  .halide-defect-pill.light_leak {{
@@ -888,11 +971,6 @@ footer a:hover {{
888
  z-index: 1;
889
  }}
890
 
891
- #halide-compare {{
892
- min-height: 520px !important;
893
- border: 1px solid rgba(243, 234, 219, 0.16) !important;
894
- }}
895
-
896
  .halide-review-actions {{
897
  display: flex;
898
  flex-wrap: wrap;
@@ -908,9 +986,9 @@ footer a:hover {{
908
  min-height: 36px;
909
  padding: 0 12px;
910
  border-radius: 8px;
911
- border: 1px solid rgba(102, 212, 193, 0.34);
912
- background: rgba(102, 212, 193, 0.08);
913
- color: #dffcf6 !important;
914
  text-decoration: none !important;
915
  font-size: 0.78rem;
916
  font-weight: 820;
@@ -918,8 +996,8 @@ footer a:hover {{
918
 
919
  .halide-review-actions a:hover,
920
  .halide-history-preview-actions a:hover {{
921
- border-color: rgba(102, 212, 193, 0.62);
922
- background: rgba(102, 212, 193, 0.13);
923
  }}
924
 
925
  .halide-report-subheading {{
@@ -938,9 +1016,9 @@ footer a:hover {{
938
  }}
939
 
940
  .halide-report-body code {{
941
- color: #dffcf6;
942
- background: rgba(102, 212, 193, 0.10);
943
- border: 1px solid rgba(102, 212, 193, 0.22);
944
  border-radius: 5px;
945
  padding: 1px 5px;
946
  }}
@@ -1104,7 +1182,7 @@ footer a:hover {{
1104
  height: 190px !important;
1105
  }}
1106
 
1107
- #halide-compare {{
1108
  min-height: 360px !important;
1109
  }}
1110
 
@@ -1143,17 +1221,17 @@ def build_theme() -> gr.Theme:
1143
  c950="#1c1209",
1144
  ),
1145
  secondary_hue=gr.themes.Color(
1146
- c50="#effffb",
1147
- c100="#d5fff7",
1148
- c200="#a8f5ea",
1149
  c300=TEAL,
1150
- c400="#3fbfae",
1151
- c500="#259d90",
1152
- c600="#1d7d74",
1153
- c700="#1b645e",
1154
- c800="#174d49",
1155
- c900="#123c39",
1156
- c950="#071f1d",
1157
  ),
1158
  neutral_hue=gr.themes.Color(
1159
  c50="#fbf7ef",
 
4
 
5
  import gradio as gr
6
 
7
+ BRASS = "#d29a45"
8
+ BRASS_DARK = "#8c5a1f"
9
+ COPPER = "#be5f38"
10
+ TEAL = "#9aae6f"
11
+ VIOLET = "#8b5e4f"
12
+ RED = "#d85c45"
13
+
14
+ INK = "#100d0a"
15
+ CARBON = "#17110d"
16
+ SURFACE = "#1f1711"
17
+ SURFACE_SOFT = "#2b2118"
18
+ SURFACE_LIFT = "#3a2b1f"
19
+ PAPER = "#fff0d8"
20
+ PAPER_SOFT = "#e6d1b6"
21
+ MUTED = "#b89c77"
22
+ BORDER = "#5a432f"
23
+ BLACK = "#080604"
24
 
25
  THEME_CSS = f"""
26
  :root {{
 
60
  background:
61
  repeating-linear-gradient(
62
  90deg,
63
+ rgba(255, 240, 216, 0.018) 0,
64
+ rgba(255, 240, 216, 0.018) 1px,
65
  transparent 1px,
66
  transparent 16px
67
  );
 
127
  display: flex;
128
  flex-wrap: wrap;
129
  justify-content: flex-end;
130
+ gap: 10px;
131
  min-width: min(42vw, 36rem);
132
  }}
133
 
 
137
  color: var(--halide-paper);
138
  background: rgba(24, 23, 21, 0.92);
139
  border-radius: 8px;
140
+ padding: 9px 13px;
141
  font-size: 0.76rem;
142
  font-weight: 780;
143
+ line-height: 1.08;
144
  text-decoration: none;
145
  white-space: nowrap;
146
  }}
147
 
148
  .halide-model-strip a {{
149
+ color: #f5dfac;
150
+ border-color: rgba(154, 174, 111, 0.42);
151
+ background: rgba(58, 43, 31, 0.92);
152
  }}
153
 
154
  .halide-workbench {{
 
189
  }}
190
 
191
  .halide-model-card {{
192
+ border: 1px solid rgba(154, 174, 111, 0.30);
193
+ background: rgba(154, 174, 111, 0.075);
194
  border-radius: 8px;
195
  padding: 12px;
196
  margin-top: 12px;
 
244
  }}
245
 
246
  .halide-run-state.active {{
247
+ border-color: rgba(154, 174, 111, 0.48);
248
  }}
249
 
250
  .halide-run-state.quiet {{
251
+ border-color: rgba(139, 94, 79, 0.38);
252
  }}
253
 
254
  .halide-run-eyebrow {{
 
260
  }}
261
 
262
  .halide-lighttable {{
263
+ background: #120d09 !important;
264
  border: 1px solid rgba(197, 154, 82, 0.34) !important;
265
  border-radius: 8px !important;
266
  padding: 13px !important;
 
359
  }}
360
 
361
  .halide-empty-lighttable.active {{
362
+ border-color: rgba(154, 174, 111, 0.36);
363
  }}
364
 
365
  .halide-empty-lighttable.active .halide-empty-center {{
366
+ border-color: rgba(154, 174, 111, 0.44);
367
  }}
368
 
369
  .halide-empty-lighttable.active .halide-empty-center span {{
 
404
  line-height: 1;
405
  }}
406
 
 
407
  .halide-review-gallery {{
408
  background: var(--halide-black) !important;
409
  }}
 
413
  }}
414
 
415
  .halide-upload img,
 
416
  .halide-review-gallery img {{
417
  background: var(--halide-black) !important;
418
  object-fit: contain !important;
419
  }}
420
 
421
+ #halide-compare {{
422
+ min-height: 0 !important;
423
+ }}
424
+
425
+ .halide-compare-viewer {{
426
+ display: grid;
427
+ gap: 12px;
428
+ background: var(--halide-black);
429
+ border: 1px solid rgba(255, 240, 216, 0.16);
430
+ border-radius: 8px;
431
+ padding: 12px;
432
+ }}
433
+
434
+ .halide-compare-stage {{
435
+ position: relative;
436
+ min-height: clamp(380px, 58vh, 720px);
437
+ overflow: hidden;
438
+ display: grid;
439
+ place-items: center;
440
+ background:
441
+ linear-gradient(180deg, rgba(31, 23, 17, 0.82), rgba(8, 6, 4, 0.98)),
442
+ repeating-linear-gradient(
443
+ 90deg,
444
+ rgba(210, 154, 69, 0.055) 0,
445
+ rgba(210, 154, 69, 0.055) 1px,
446
+ transparent 1px,
447
+ transparent 34px
448
+ );
449
+ border-radius: 7px;
450
+ }}
451
+
452
+ .halide-compare-stage img {{
453
+ position: absolute;
454
+ inset: 0;
455
+ width: 100%;
456
+ height: 100%;
457
+ object-fit: contain;
458
+ background: var(--halide-black);
459
+ }}
460
+
461
+ .halide-compare-overlay {{
462
+ position: absolute;
463
+ inset: 0;
464
+ clip-path: inset(0 calc(100% - var(--halide-split)) 0 0);
465
+ }}
466
+
467
+ .halide-compare-divider {{
468
+ position: absolute;
469
+ top: 0;
470
+ bottom: 0;
471
+ left: var(--halide-split);
472
+ width: 2px;
473
+ transform: translateX(-1px);
474
+ background: rgba(255, 240, 216, 0.88);
475
+ box-shadow: 0 0 0 1px rgba(8, 6, 4, 0.70), 0 0 18px rgba(190, 95, 56, 0.38);
476
+ }}
477
+
478
+ .halide-compare-label {{
479
+ position: absolute;
480
+ top: 12px;
481
+ z-index: 2;
482
+ border: 1px solid rgba(255, 240, 216, 0.20);
483
+ background: rgba(16, 13, 10, 0.78);
484
+ color: var(--halide-paper);
485
+ border-radius: 999px;
486
+ padding: 6px 9px;
487
+ font-size: 0.70rem;
488
+ font-weight: 820;
489
+ line-height: 1;
490
+ }}
491
+
492
+ .halide-compare-label.original {{
493
+ left: 12px;
494
+ }}
495
+
496
+ .halide-compare-label.overlay {{
497
+ right: 12px;
498
+ }}
499
+
500
+ .halide-compare-range {{
501
+ width: 100%;
502
+ accent-color: var(--halide-copper);
503
+ }}
504
+
505
  #halide-run-button,
506
  button.primary,
507
  .primary button {{
 
594
  }}
595
 
596
  .halide-notice.good {{
597
+ border-color: rgba(154, 174, 111, 0.40);
598
+ background: rgba(154, 174, 111, 0.085);
599
  }}
600
 
601
  .halide-notice.caution {{
 
604
  }}
605
 
606
  .halide-notice.neutral {{
607
+ border-color: rgba(139, 94, 79, 0.38);
608
+ background: rgba(139, 94, 79, 0.085);
609
  }}
610
 
611
  .halide-stats {{
 
729
  }}
730
 
731
  .halide-defect-pill.long_hair {{
732
+ background: rgba(139, 94, 79, 0.14);
733
+ border-color: rgba(139, 94, 79, 0.40);
734
  }}
735
 
736
  .halide-defect-pill.short_hair {{
737
+ background: rgba(154, 174, 111, 0.12);
738
+ border-color: rgba(154, 174, 111, 0.38);
739
  }}
740
 
741
  .halide-defect-pill.emulsion_damage {{
 
744
  }}
745
 
746
  .halide-defect-pill.chemical_stain {{
747
+ background: rgba(154, 174, 111, 0.13);
748
+ border-color: rgba(154, 174, 111, 0.38);
749
  }}
750
 
751
  .halide-defect-pill.light_leak {{
 
971
  z-index: 1;
972
  }}
973
 
 
 
 
 
 
974
  .halide-review-actions {{
975
  display: flex;
976
  flex-wrap: wrap;
 
986
  min-height: 36px;
987
  padding: 0 12px;
988
  border-radius: 8px;
989
+ border: 1px solid rgba(154, 174, 111, 0.38);
990
+ background: rgba(154, 174, 111, 0.09);
991
+ color: #f5dfac !important;
992
  text-decoration: none !important;
993
  font-size: 0.78rem;
994
  font-weight: 820;
 
996
 
997
  .halide-review-actions a:hover,
998
  .halide-history-preview-actions a:hover {{
999
+ border-color: rgba(154, 174, 111, 0.62);
1000
+ background: rgba(154, 174, 111, 0.14);
1001
  }}
1002
 
1003
  .halide-report-subheading {{
 
1016
  }}
1017
 
1018
  .halide-report-body code {{
1019
+ color: #f5dfac;
1020
+ background: rgba(154, 174, 111, 0.10);
1021
+ border: 1px solid rgba(154, 174, 111, 0.24);
1022
  border-radius: 5px;
1023
  padding: 1px 5px;
1024
  }}
 
1182
  height: 190px !important;
1183
  }}
1184
 
1185
+ .halide-compare-stage {{
1186
  min-height: 360px !important;
1187
  }}
1188
 
 
1221
  c950="#1c1209",
1222
  ),
1223
  secondary_hue=gr.themes.Color(
1224
+ c50="#f8faec",
1225
+ c100="#eef3d1",
1226
+ c200="#dbe6a9",
1227
  c300=TEAL,
1228
+ c400="#7f9857",
1229
+ c500="#667a43",
1230
+ c600="#506236",
1231
+ c700="#3e4d2b",
1232
+ c800="#303c22",
1233
+ c900="#252f1b",
1234
+ c950="#131a0d",
1235
  ),
1236
  neutral_hue=gr.themes.Color(
1237
  c50="#fbf7ef",