prthm11 commited on
Commit
bc7ce06
·
verified ·
1 Parent(s): 25d56d2

Sync from GitHub a877e8c

Browse files
.env.example CHANGED
@@ -11,6 +11,7 @@ GROQ_API_KEY=
11
  # DEBUG=false
12
  # MAX_UPLOAD_MB=50
13
  # PRELOAD_MODELS=true # load models at startup instead of on the first request
 
14
  # APP_BASE_DIR= # defaults to the project folder
15
 
16
  # --- LLM ---
@@ -29,6 +30,7 @@ GROQ_API_KEY=
29
  # USE_IMAGE_SIGNATURE=true
30
  # MATCH_WEIGHTS=0.5,0.3,0.2 # embedding, phash, image-signature
31
  # MIN_MATCH_COSINE=0.4 # reject figures whose best raw DINOv2 cosine is below this (0 = off); read "cos=" in the logs to calibrate
 
32
  # REJECT_DIR=_rejects # folder in the asset library with non-asset examples (code-block screenshots); figures closer to these are dropped
33
  # DINOV2_MODEL=facebook/dinov2-small # must be the model the embeddings were generated with
34
  # TORCH_CPU_THREADS=4
@@ -36,5 +38,7 @@ GROQ_API_KEY=
36
 
37
  # --- Pipeline ---
38
  # PDF_DPI=300
 
 
39
  # GRAPH_RECURSION_LIMIT=200 # raised automatically for long PDFs
40
  # ZOOM_CODE_REGION=true # also send a full-resolution crop of the code blocks (fixes misread values/dropped repeats)
 
11
  # DEBUG=false
12
  # MAX_UPLOAD_MB=50
13
  # PRELOAD_MODELS=true # load models at startup instead of on the first request
14
+ # KEEP_WARM_INTERVAL_S=300 # after this many idle seconds, run a dummy inference so the OS keeps models in RAM (0 = off)
15
  # APP_BASE_DIR= # defaults to the project folder
16
 
17
  # --- LLM ---
 
30
  # USE_IMAGE_SIGNATURE=true
31
  # MATCH_WEIGHTS=0.5,0.3,0.2 # embedding, phash, image-signature
32
  # MIN_MATCH_COSINE=0.4 # reject figures whose best raw DINOv2 cosine is below this (0 = off); read "cos=" in the logs to calibrate
33
+ # MATCH_MAX_SIDE=512 # downscale figures to this longest side before enhancement (0 = off); ~2.4x faster matching
34
  # REJECT_DIR=_rejects # folder in the asset library with non-asset examples (code-block screenshots); figures closer to these are dropped
35
  # DINOV2_MODEL=facebook/dinov2-small # must be the model the embeddings were generated with
36
  # TORCH_CPU_THREADS=4
 
38
 
39
  # --- Pipeline ---
40
  # PDF_DPI=300
41
+ # FIGURE_EXTRACTOR=layout # layout = YOLOX on the rendered pages (~1 s/page); hi_res = unstructured partition_pdf with OCR (~10 s/page)
42
+ # LAYOUT_MODEL=yolox # unstructured_inference model for the layout extractor (yolox, yolox_tiny, yolox_quantized)
43
  # GRAPH_RECURSION_LIMIT=200 # raised automatically for long PDFs
44
  # ZOOM_CODE_REGION=true # also send a full-resolution crop of the code blocks (fixes misread values/dropped repeats)
Dockerfile CHANGED
@@ -6,7 +6,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
6
  PYTHONUNBUFFERED=1 \
7
  PYTHONDONTWRITEBYTECODE=1
8
 
9
- # poppler: pdf2image page rendering; tesseract: OCR inside unstructured's hi_res strategy;
10
  # libgl1/libglib2.0-0: OpenCV runtime.
11
  RUN apt-get update && apt-get install -y --no-install-recommends \
12
  poppler-utils \
 
6
  PYTHONUNBUFFERED=1 \
7
  PYTHONDONTWRITEBYTECODE=1
8
 
9
+ # poppler: pdf2image page rendering; tesseract: OCR for the FIGURE_EXTRACTOR=hi_res fallback only;
10
  # libgl1/libglib2.0-0: OpenCV runtime.
11
  RUN apt-get update && apt-get install -y --no-install-recommends \
12
  poppler-utils \
scratch_vision/__init__.py CHANGED
@@ -26,7 +26,7 @@ def create_app(settings: Optional[Settings] = None) -> Flask:
26
  """Application factory: configure logging, validate settings, register routes, load models."""
27
  from .api.errors import register_error_handlers
28
  from .api.routes import bp
29
- from .core.models import warmup
30
 
31
  settings = settings or get_settings()
32
  setup_logging(settings)
@@ -44,6 +44,7 @@ def create_app(settings: Optional[Settings] = None) -> Flask:
44
 
45
  if settings.preload_models:
46
  warmup()
 
47
  return app
48
 
49
 
 
26
  """Application factory: configure logging, validate settings, register routes, load models."""
27
  from .api.errors import register_error_handlers
28
  from .api.routes import bp
29
+ from .core.models import start_keep_warm, warmup
30
 
31
  settings = settings or get_settings()
32
  setup_logging(settings)
 
44
 
45
  if settings.preload_models:
46
  warmup()
47
+ start_keep_warm(settings.keep_warm_interval_s)
48
  return app
49
 
50
 
scratch_vision/config.py CHANGED
@@ -46,6 +46,7 @@ class Settings:
46
  public_base_url: str # e.g. https://<space>.hf.space ; empty -> derived from the request
47
  max_upload_mb: int
48
  preload_models: bool
 
49
 
50
  # --- LLM ---
51
  groq_api_key: str
@@ -64,8 +65,11 @@ class Settings:
64
  use_image_signature: bool # include the Goldberg image-signature signal in matching
65
  min_match_cosine: float # reject a match whose raw DINOv2 cosine is below this (0 = off); real matches ~0.65+, code blocks ~0.1
66
  reject_dir_name: str # folder in the asset library with images that are NOT assets (e.g. code blocks)
 
67
 
68
  # --- Pipeline ---
 
 
69
  pdf_dpi: int
70
  graph_recursion_limit: int
71
  zoom_code_region: bool # also send a full-resolution crop of the code blocks with each page
@@ -121,6 +125,8 @@ class Settings:
121
  raise RuntimeError("GROQ_API_KEY is not set. Add it to .env or the Space secrets.")
122
  if not self.assets_dir.is_dir():
123
  raise RuntimeError(f"Asset library not found at {self.assets_dir}")
 
 
124
 
125
 
126
  def _resolve_base_dir() -> Path:
@@ -161,6 +167,7 @@ def get_settings() -> Settings:
161
  public_base_url=_env_str("PUBLIC_BASE_URL").rstrip("/"),
162
  max_upload_mb=_env_int("MAX_UPLOAD_MB", 50),
163
  preload_models=_env_bool("PRELOAD_MODELS", True),
 
164
  groq_api_key=_env_str("GROQ_API_KEY"),
165
  llm_model=_env_str("LLM_MODEL", "qwen/qwen3.8-27b"),
166
  llm_temperature=_env_float("LLM_TEMPERATURE", 0.0),
@@ -175,6 +182,9 @@ def get_settings() -> Settings:
175
  use_image_signature=_env_bool("USE_IMAGE_SIGNATURE", True),
176
  min_match_cosine=_env_float("MIN_MATCH_COSINE", 0.4),
177
  reject_dir_name=_env_str("REJECT_DIR", "_rejects"),
 
 
 
178
  pdf_dpi=_env_int("PDF_DPI", 300),
179
  graph_recursion_limit=_env_int("GRAPH_RECURSION_LIMIT", 200),
180
  zoom_code_region=_env_bool("ZOOM_CODE_REGION", True),
 
46
  public_base_url: str # e.g. https://<space>.hf.space ; empty -> derived from the request
47
  max_upload_mb: int
48
  preload_models: bool
49
+ keep_warm_interval_s: float # re-run a dummy inference after this much idle time so models stay in RAM (0 = off)
50
 
51
  # --- LLM ---
52
  groq_api_key: str
 
65
  use_image_signature: bool # include the Goldberg image-signature signal in matching
66
  min_match_cosine: float # reject a match whose raw DINOv2 cosine is below this (0 = off); real matches ~0.65+, code blocks ~0.1
67
  reject_dir_name: str # folder in the asset library with images that are NOT assets (e.g. code blocks)
68
+ match_max_side: int # downscale figures to this longest side before enhancement (0 = off); enhancing is O(pixels)
69
 
70
  # --- Pipeline ---
71
+ figure_extractor: str # "layout": layout model on the rendered pages (fast); "hi_res": unstructured partition_pdf (OCR, slow)
72
+ layout_model: str # unstructured_inference layout model used by the "layout" extractor
73
  pdf_dpi: int
74
  graph_recursion_limit: int
75
  zoom_code_region: bool # also send a full-resolution crop of the code blocks with each page
 
125
  raise RuntimeError("GROQ_API_KEY is not set. Add it to .env or the Space secrets.")
126
  if not self.assets_dir.is_dir():
127
  raise RuntimeError(f"Asset library not found at {self.assets_dir}")
128
+ if self.figure_extractor not in ("layout", "hi_res"):
129
+ raise RuntimeError(f"FIGURE_EXTRACTOR must be 'layout' or 'hi_res', got '{self.figure_extractor}'")
130
 
131
 
132
  def _resolve_base_dir() -> Path:
 
167
  public_base_url=_env_str("PUBLIC_BASE_URL").rstrip("/"),
168
  max_upload_mb=_env_int("MAX_UPLOAD_MB", 50),
169
  preload_models=_env_bool("PRELOAD_MODELS", True),
170
+ keep_warm_interval_s=_env_float("KEEP_WARM_INTERVAL_S", 300.0),
171
  groq_api_key=_env_str("GROQ_API_KEY"),
172
  llm_model=_env_str("LLM_MODEL", "qwen/qwen3.8-27b"),
173
  llm_temperature=_env_float("LLM_TEMPERATURE", 0.0),
 
182
  use_image_signature=_env_bool("USE_IMAGE_SIGNATURE", True),
183
  min_match_cosine=_env_float("MIN_MATCH_COSINE", 0.4),
184
  reject_dir_name=_env_str("REJECT_DIR", "_rejects"),
185
+ match_max_side=_env_int("MATCH_MAX_SIDE", 512),
186
+ figure_extractor=_env_str("FIGURE_EXTRACTOR", "layout").lower(),
187
+ layout_model=_env_str("LAYOUT_MODEL", "yolox"),
188
  pdf_dpi=_env_int("PDF_DPI", 300),
189
  graph_recursion_limit=_env_int("GRAPH_RECURSION_LIMIT", 200),
190
  zoom_code_region=_env_bool("ZOOM_CODE_REGION", True),
scratch_vision/core/models.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
 
8
  import logging
9
  import threading
 
10
  from dataclasses import dataclass
11
  from typing import Any
12
 
@@ -26,6 +27,7 @@ class Dinov2Bundle:
26
  _dinov2 = None
27
  _phash = None
28
  _gis = None
 
29
  _llm = None
30
 
31
 
@@ -70,6 +72,20 @@ def get_image_signature():
70
  return _gis
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def get_llm():
74
  global _llm
75
  if _llm is None:
@@ -97,6 +113,69 @@ def warmup() -> None:
97
  get_dinov2()
98
  get_phash()
99
  get_image_signature()
 
 
100
  get_llm()
101
  get_reference_index()
102
  logger.info("All models and the reference index are loaded")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  import logging
9
  import threading
10
+ import time
11
  from dataclasses import dataclass
12
  from typing import Any
13
 
 
27
  _dinov2 = None
28
  _phash = None
29
  _gis = None
30
+ _layout = None
31
  _llm = None
32
 
33
 
 
72
  return _gis
73
 
74
 
75
+ def get_layout_model():
76
+ """Layout detection model (YOLOX by default) that finds the figures on each PDF page."""
77
+ global _layout
78
+ if _layout is None:
79
+ with _lock:
80
+ if _layout is None:
81
+ from unstructured_inference.models.base import get_model
82
+
83
+ name = get_settings().layout_model
84
+ logger.info("Loading layout model %s", name)
85
+ _layout = get_model(name)
86
+ return _layout
87
+
88
+
89
  def get_llm():
90
  global _llm
91
  if _llm is None:
 
113
  get_dinov2()
114
  get_phash()
115
  get_image_signature()
116
+ if get_settings().figure_extractor == "layout":
117
+ get_layout_model()
118
  get_llm()
119
  get_reference_index()
120
  logger.info("All models and the reference index are loaded")
121
+ run_dummy_inference()
122
+
123
+
124
+ def run_dummy_inference() -> None:
125
+ """One tiny pass through every local model and the PDF renderer.
126
+
127
+ Loading a model is not enough: the first real inference also pays for lazy kernel/session setup,
128
+ and after a long idle the OS pages the weights out, which made the first request take ~1 minute
129
+ instead of ~10s. Running this at boot and periodically while idle keeps that cost off requests.
130
+ """
131
+ import tempfile
132
+ from pathlib import Path
133
+
134
+ import numpy as np
135
+ from PIL import Image
136
+
137
+ from ..pdf.extraction import render_pages
138
+ from ..vision.embeddings import get_dinov2_embedding
139
+
140
+ start = time.perf_counter()
141
+ try:
142
+ blank = Image.new("RGB", (620, 877), "white") # A4 at 75 dpi
143
+ with tempfile.TemporaryDirectory() as tmp:
144
+ pdf_path = Path(tmp) / "warmup.pdf"
145
+ blank.save(pdf_path, format="PDF")
146
+ render_pages(pdf_path, 72)
147
+ if get_settings().figure_extractor == "layout":
148
+ get_layout_model().image_processing(blank)
149
+ get_dinov2_embedding(blank.resize((224, 224)))
150
+ get_phash().encode_image(image_array=np.asarray(blank.convert("L").resize((64, 64))))
151
+ except Exception as e: # warming is best effort; never take the app down over it
152
+ logger.warning("Dummy inference failed: %s", e)
153
+ return
154
+ logger.info("Dummy inference finished in %.2fs", time.perf_counter() - start)
155
+
156
+
157
+ _last_activity = time.monotonic()
158
+
159
+
160
+ def mark_active() -> None:
161
+ """Called by each request so the keep-warm thread only runs while the app is idle."""
162
+ global _last_activity
163
+ _last_activity = time.monotonic()
164
+
165
+
166
+ def start_keep_warm(interval_s: float) -> None:
167
+ """Daemon thread that re-runs the dummy inference after every `interval_s` seconds of idle time."""
168
+ if interval_s <= 0:
169
+ return
170
+
171
+ def loop():
172
+ while True:
173
+ idle = time.monotonic() - _last_activity
174
+ if idle < interval_s:
175
+ time.sleep(interval_s - idle)
176
+ continue
177
+ run_dummy_inference()
178
+ mark_active()
179
+
180
+ threading.Thread(target=loop, name="keep-warm", daemon=True).start()
181
+ logger.info("Keep-warm thread started (every %ss of idle time)", interval_s)
scratch_vision/pdf/extraction.py CHANGED
@@ -1,7 +1,8 @@
1
- """PDF input handling: persist the upload, extract embedded figures, render pages."""
2
  from __future__ import annotations
3
 
4
  import base64
 
5
  import logging
6
  import uuid
7
  from pathlib import Path
@@ -60,6 +61,53 @@ def extract_figures(pdf_path: Path, output_dir: Path) -> Dict[str, dict]:
60
  return figures
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def render_pages(pdf_path: Path, dpi: int) -> List[Image.Image]:
64
  from pdf2image import convert_from_path
65
 
 
1
+ """PDF input handling: persist the upload, render pages, extract the figures on them."""
2
  from __future__ import annotations
3
 
4
  import base64
5
+ import io
6
  import logging
7
  import uuid
8
  from pathlib import Path
 
61
  return figures
62
 
63
 
64
+ MIN_FIGURE_SIDE = 32 # px at PDF_DPI; smaller boxes are icons or detection noise
65
+ ROW_TOLERANCE = 0.05 # boxes whose tops are within 5% of the page height count as one row
66
+
67
+
68
+ def extract_figures_from_pages(pages: List[Image.Image]) -> Dict[str, dict]:
69
+ """Detect figures on already-rendered pages with the layout model and crop them out.
70
+
71
+ Same result shape as `extract_figures`, but skips partition_pdf's second render, full-page OCR and
72
+ text merging, which the app never uses. Figures are numbered page by page, top to bottom, left to right.
73
+ """
74
+ from unstructured_inference.constants import ElementType
75
+
76
+ from ..core.models import get_layout_model
77
+
78
+ model = get_layout_model()
79
+ pdf_id = uuid.uuid4().hex
80
+ figures: Dict[str, dict] = {}
81
+ for page_no, page in enumerate(pages, start=1):
82
+ rgb = page.convert("RGB")
83
+ try:
84
+ layout = model.image_processing(rgb)
85
+ except Exception as e:
86
+ raise PdfExtractionError(f"Layout detection failed on page {page_no}: {e}") from e
87
+
88
+ boxes = []
89
+ for coords, class_id in zip(layout.element_coords, layout.element_class_ids):
90
+ if layout.element_class_id_map.get(int(class_id)) != ElementType.PICTURE:
91
+ continue
92
+ x1, y1 = max(0, int(coords[0])), max(0, int(coords[1]))
93
+ x2, y2 = min(rgb.width, int(round(coords[2]))), min(rgb.height, int(round(coords[3])))
94
+ if x2 - x1 >= MIN_FIGURE_SIDE and y2 - y1 >= MIN_FIGURE_SIDE:
95
+ boxes.append((x1, y1, x2, y2))
96
+ boxes.sort(key=lambda b: (round(b[1] / (rgb.height * ROW_TOLERANCE)), b[0]))
97
+
98
+ for box in boxes:
99
+ buffer = io.BytesIO()
100
+ rgb.crop(box).save(buffer, format="PNG")
101
+ figures[f"Sprite {len(figures) + 1}"] = {
102
+ "base64": base64.b64encode(buffer.getvalue()).decode("utf-8"),
103
+ "file-path": None, # cropped in memory; nothing is written to disk
104
+ "pdf-id": pdf_id,
105
+ "image-uuid": str(uuid.uuid4()),
106
+ }
107
+ logger.info("Extracted %d figure(s) from %d page(s) with the layout model", len(figures), len(pages))
108
+ return figures
109
+
110
+
111
  def render_pages(pdf_path: Path, dpi: int) -> List[Image.Image]:
112
  from pdf2image import convert_from_path
113
 
scratch_vision/services/project_service.py CHANGED
@@ -10,8 +10,9 @@ from pathlib import Path
10
  from typing import Dict, List, Optional
11
 
12
  from ..config import get_settings
 
13
  from ..exceptions import InvalidUploadError
14
- from ..pdf.extraction import PDF_MAGIC, extract_figures, render_pages, save_pdf
15
  from ..pipeline.graph import get_graph
16
  from ..pipeline.nodes import NO_CODE_RESULT, find_target
17
  from ..pipeline.state import initial_state
@@ -103,6 +104,7 @@ def _attach_code(described: List[Dict], pages: List[Dict], targets: list) -> Lis
103
  def process_pdf(pdf_bytes: bytes, project_id: str) -> ProcessResult:
104
  settings = get_settings()
105
  validate_pdf(pdf_bytes)
 
106
 
107
  project_folder = settings.output_dir / project_id # becomes the .sb3 contents
108
  work_dir = settings.work_dir / project_id # private scratch space, deleted below
@@ -112,17 +114,22 @@ def process_pdf(pdf_bytes: bytes, project_id: str) -> ProcessResult:
112
  try:
113
  pdf_path = save_pdf(pdf_bytes, settings.generated_dir / project_id / f"{project_id}.pdf")
114
 
 
 
 
 
115
  # 1. Figures in the PDF -> matching sprites/backdrops from the library -> project skeleton
116
- with timed(f"[{project_id}] figure extraction", logger):
117
- figures = extract_figures(pdf_path, work_dir / "figures")
 
 
 
118
  with timed(f"[{project_id}] asset matching", logger):
119
  matches = match_images({sid: f["base64"] for sid, f in figures.items()}, work_dir)
120
  project_json_path = build_project_skeleton([m.path for m in matches if m.path], project_folder)
121
  project_json = json.loads(project_json_path.read_text(encoding="utf-8"))
122
 
123
  # 2. Each page image -> LLM pseudocode -> Scratch blocks
124
- with timed(f"[{project_id}] page rendering", logger):
125
- pages = render_pages(pdf_path, settings.pdf_dpi)
126
  recursion_limit = max(settings.graph_recursion_limit, STEPS_PER_PAGE * len(pages) + 10)
127
  with timed(f"[{project_id}] LangGraph pipeline ({len(pages)} page(s))", logger):
128
  final_state = get_graph().invoke(
 
10
  from typing import Dict, List, Optional
11
 
12
  from ..config import get_settings
13
+ from ..core.models import mark_active
14
  from ..exceptions import InvalidUploadError
15
+ from ..pdf.extraction import PDF_MAGIC, extract_figures, extract_figures_from_pages, render_pages, save_pdf
16
  from ..pipeline.graph import get_graph
17
  from ..pipeline.nodes import NO_CODE_RESULT, find_target
18
  from ..pipeline.state import initial_state
 
104
  def process_pdf(pdf_bytes: bytes, project_id: str) -> ProcessResult:
105
  settings = get_settings()
106
  validate_pdf(pdf_bytes)
107
+ mark_active()
108
 
109
  project_folder = settings.output_dir / project_id # becomes the .sb3 contents
110
  work_dir = settings.work_dir / project_id # private scratch space, deleted below
 
114
  try:
115
  pdf_path = save_pdf(pdf_bytes, settings.generated_dir / project_id / f"{project_id}.pdf")
116
 
117
+ # Rendered once: the layout model finds figures on these pages and the LLM reads them.
118
+ with timed(f"[{project_id}] page rendering", logger):
119
+ pages = render_pages(pdf_path, settings.pdf_dpi)
120
+
121
  # 1. Figures in the PDF -> matching sprites/backdrops from the library -> project skeleton
122
+ with timed(f"[{project_id}] figure extraction ({settings.figure_extractor})", logger):
123
+ if settings.figure_extractor == "hi_res":
124
+ figures = extract_figures(pdf_path, work_dir / "figures")
125
+ else:
126
+ figures = extract_figures_from_pages(pages)
127
  with timed(f"[{project_id}] asset matching", logger):
128
  matches = match_images({sid: f["base64"] for sid, f in figures.items()}, work_dir)
129
  project_json_path = build_project_skeleton([m.path for m in matches if m.path], project_folder)
130
  project_json = json.loads(project_json_path.read_text(encoding="utf-8"))
131
 
132
  # 2. Each page image -> LLM pseudocode -> Scratch blocks
 
 
133
  recursion_limit = max(settings.graph_recursion_limit, STEPS_PER_PAGE * len(pages) + 10)
134
  with timed(f"[{project_id}] LangGraph pipeline ({len(pages)} page(s))", logger):
135
  final_state = get_graph().invoke(
scratch_vision/vision/image_utils.py CHANGED
@@ -56,6 +56,14 @@ def preprocess_for_model(pil_img: Image.Image) -> Optional[Image.Image]:
56
  return None
57
 
58
 
 
 
 
 
 
 
 
 
59
  # ---------- OpenCV enhancement ----------
60
  def _pil_to_bgr(pil_img: Image.Image) -> np.ndarray:
61
  return cv2.cvtColor(np.array(pil_img.convert("RGB")), cv2.COLOR_RGB2BGR)
 
56
  return None
57
 
58
 
59
+ def cap_size(pil_img: Image.Image, max_side: int) -> Image.Image:
60
+ """Downscale so the longest side is at most max_side (0 = no cap). Never upscales."""
61
+ if max_side <= 0 or max(pil_img.size) <= max_side:
62
+ return pil_img
63
+ ratio = max_side / max(pil_img.size)
64
+ return pil_img.resize((max(1, round(pil_img.width * ratio)), max(1, round(pil_img.height * ratio))), Image.LANCZOS)
65
+
66
+
67
  # ---------- OpenCV enhancement ----------
68
  def _pil_to_bgr(pil_img: Image.Image) -> np.ndarray:
69
  return cv2.cvtColor(np.array(pil_img.convert("RGB")), cv2.COLOR_RGB2BGR)
scratch_vision/vision/reference_index.py CHANGED
@@ -89,7 +89,7 @@ def _embed_reject_examples(folder: Path) -> np.ndarray:
89
  from PIL import Image
90
 
91
  from .embeddings import get_dinov2_embedding
92
- from .image_utils import enhance_image, preprocess_for_model
93
 
94
  if not folder.is_dir():
95
  return np.empty((0, 0))
@@ -98,7 +98,8 @@ def _embed_reject_examples(folder: Path) -> np.ndarray:
98
  try:
99
  with Image.open(img_path) as img:
100
  img.load()
101
- enhanced = enhance_image(img, scale=2) or img
 
102
  emb = get_dinov2_embedding(preprocess_for_model(enhanced))
103
  except Exception as e:
104
  logger.warning("Could not embed reject example %s: %s", img_path, e)
 
89
  from PIL import Image
90
 
91
  from .embeddings import get_dinov2_embedding
92
+ from .image_utils import cap_size, enhance_image, preprocess_for_model
93
 
94
  if not folder.is_dir():
95
  return np.empty((0, 0))
 
98
  try:
99
  with Image.open(img_path) as img:
100
  img.load()
101
+ capped = cap_size(img, get_settings().match_max_side)
102
+ enhanced = enhance_image(capped, scale=2) or capped
103
  emb = get_dinov2_embedding(preprocess_for_model(enhanced))
104
  except Exception as e:
105
  logger.warning("Could not embed reject example %s: %s", img_path, e)
scratch_vision/vision/search.py CHANGED
@@ -14,7 +14,7 @@ from PIL import Image
14
  from ..config import get_settings
15
  from ..core.models import get_image_signature, get_phash
16
  from .embeddings import get_dinov2_embedding
17
- from .image_utils import base64_to_pil, enhance_image, preprocess_for_hash, preprocess_for_model
18
  from .reference_index import ReferenceIndex, get_reference_index
19
 
20
  logger = logging.getLogger(__name__)
@@ -36,6 +36,8 @@ class MatchResult:
36
 
37
  # ---------- 1. features of the query image ----------
38
  def compute_query_features(pil_img: Image.Image, work_dir: Path, name: str) -> Optional[QueryFeatures]:
 
 
39
  enhanced = enhance_image(pil_img, scale=2) or pil_img
40
 
41
  embedding = get_dinov2_embedding(preprocess_for_model(enhanced))
 
14
  from ..config import get_settings
15
  from ..core.models import get_image_signature, get_phash
16
  from .embeddings import get_dinov2_embedding
17
+ from .image_utils import base64_to_pil, cap_size, enhance_image, preprocess_for_hash, preprocess_for_model
18
  from .reference_index import ReferenceIndex, get_reference_index
19
 
20
  logger = logging.getLogger(__name__)
 
36
 
37
  # ---------- 1. features of the query image ----------
38
  def compute_query_features(pil_img: Image.Image, work_dir: Path, name: str) -> Optional[QueryFeatures]:
39
+ # Enhancement cost grows with pixel count, and DINOv2 resizes to 224 px anyway: cap the size first.
40
+ pil_img = cap_size(pil_img, get_settings().match_max_side)
41
  enhanced = enhance_image(pil_img, scale=2) or pil_img
42
 
43
  embedding = get_dinov2_embedding(preprocess_for_model(enhanced))