gvlktejaswi commited on
Commit
bcfc87f
·
verified ·
1 Parent(s): 2760712

Update page_files/categorized/Backend/Pdf_ImageExtraction.py

Browse files
page_files/categorized/Backend/Pdf_ImageExtraction.py CHANGED
@@ -1,374 +1,1192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
 
 
 
 
 
2
 
 
 
3
  import requests
 
 
 
 
 
 
 
 
4
 
5
  log = logging.getLogger(__name__)
 
6
 
7
- import streamlit as st
8
- from google import genai
9
- import fitz # PyMuPDF
10
- import json
11
- import tempfile
12
- import re
13
- import os
14
 
15
- import numpy as np
16
- import cv2
17
- from typing import Optional, Tuple
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
- # ----------------------------
22
- # Gemini init (more deterministic)
23
- # ----------------------------
24
- API_KEY = os.getenv("GEMINI_API_KEY", " ")
25
 
26
- _cached_model: Optional[str] = None
27
-
28
 
29
- def get_available_model(api_key: str) -> str:
30
- """Fetch available models and return best flash model found."""
31
- global _cached_model
32
- if _cached_model:
33
- return _cached_model
 
34
  try:
35
  resp = requests.get(
36
  f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}",
37
- timeout=10
38
  )
39
  if not resp.ok:
40
- _cached_model = "gemini-2.0-flash"
41
- return _cached_model
42
  models = resp.json().get("models", [])
43
- model_names = [m["name"].replace("models/", "") for m in models
44
- if "generateContent" in m.get("supportedGenerationMethods", [])]
45
- preferred = [
46
- "gemini-2.5-flash",
47
- "gemini-2.5-flash-preview",
48
- "gemini-2.0-flash",
49
- "gemini-2.0-flash-lite",
50
  ]
51
- for pref in preferred:
52
- for name in model_names:
53
- if name.startswith(pref):
54
- log.info(f"Auto-selected model: {name}")
55
- _cached_model = name
56
- return _cached_model
57
- flash_models = [n for n in model_names if "flash" in n]
58
- _cached_model = flash_models[0] if flash_models else "gemini-2.0-flash"
59
- return _cached_model
60
  except Exception as e:
61
- log.warning(f"Model auto-detect failed: {e}")
62
- _cached_model = "gemini-2.0-flash"
63
- return _cached_model
64
-
65
- GEMINI_MODEL = None
66
- API_URL = None
67
-
68
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
 
71
- def robust_json_load(text: str):
72
  """
73
- Handles cases where Gemini returns JSON + extra text.
 
 
 
 
 
 
 
74
  """
75
- if not text:
76
- raise ValueError("Empty model output")
77
-
78
- cleaned = text.strip().replace("```json", "```").replace("```", "").strip()
79
-
80
- # Find first JSON start
81
- s1 = cleaned.find("[")
82
- s2 = cleaned.find("{")
83
- if s1 == -1 and s2 == -1:
84
- raise ValueError("No JSON found in model output")
85
-
86
- start = s1 if (s1 != -1 and (s2 == -1 or s1 < s2)) else s2
87
- dec = json.JSONDecoder()
88
- parsed, _ = dec.raw_decode(cleaned[start:])
89
- return parsed
90
-
91
-
92
- def get_plot_data_from_llm(client, model_name: str, pdf_path: str):
93
- sample_file = client.files.upload(path=pdf_path)
94
- prompt = prompt = """
95
- Analyze this PDF and identify ONLY true data plots:
96
- (Line, Scatter, Bar, Histogram, Heatmap, Box, Violin).
97
- EXCLUDE: flowcharts, block diagrams, photos, icons, gauges/dials,
98
- graphical abstracts, tables, and pure text regions.
99
-
100
- DETECTION RULES:
101
- 1) INCLUDE ALL SUBPLOTS: If a figure has panels like (a), (b), (c),
102
- return ONE bounding box covering the ENTIRE figure group including
103
- all panels, axes, and legends.
104
- 2) CROP AREA: box_2d MUST include:
105
- - The full plot area (all data points, bars, lines)
106
- - ALL axis lines and tick marks
107
- - ALL axis labels and tick labels
108
- - The legend if present
109
- - A small margin above the top of the plot
110
- box_2d MUST EXCLUDE figure caption text below the figure.
111
- 3) IMPORTANT: Make the bounding box GENEROUS — it is better to include
112
- too much whitespace than to cut off any part of the plot.
113
- 4) DO NOT return tables or paragraphs as images.
114
- 5) Skip graphical abstracts and decorative figures.
115
- 6) Extract the FULL caption text into "caption".
116
-
117
- COORDINATES:
118
- - box_2d is [ymin, xmin, ymax, xmax] in 0..1000 normalized page coordinates.
119
- - ymin should be ABOVE the top of the plot area.
120
- - ymax should be BELOW the bottom axis but ABOVE the caption text.
121
- - xmin should be LEFT of the y-axis labels.
122
- - xmax should be RIGHT of the rightmost data point or legend.
123
-
124
- Return ONLY a raw JSON array like:
125
- [
126
- {
127
- "caption":"...",
128
- "page": 1,
129
- "box_2d":[ymin,xmin,ymax,xmax],
130
- "figure_kind":"plot|nonplot",
131
- "plot_type":"scatter|line|bar|hist|heatmap|box|violin|other"
132
- }
133
- ]
134
- IMPORTANT: Only include items where figure_kind="plot".
135
- """
136
-
137
- # Try to force JSON + reduce randomness
138
- generation_config = {
139
- "temperature": 0.0,
140
- "top_p": 1.0,
141
- "top_k": 1,
142
- "max_output_tokens": 4096,
143
- }
144
-
145
- # Some environments accept response_mime_type; if yours errors, remove it.
146
- response = client.models.generate_content(
147
- model=model_name,
148
- contents=[sample_file, prompt],
149
- config={"temperature": 0.0, "max_output_tokens": 4096}
150
- )
151
- data = robust_json_load(response.text)
152
-
153
- # Hard filter if model still returns nonplots
154
- filtered = []
155
- for it in data if isinstance(data, list) else []:
156
- if not isinstance(it, dict):
157
  continue
158
- if (it.get("figure_kind") or "").strip().lower() == "plot":
159
- filtered.append(it)
160
- return filtered
161
-
162
-
163
- # ----------------------------
164
- # Caption trimming using PDF text blocks
165
- # ----------------------------
166
- CAP_RE = re.compile(r"^(fig\.?\s*\d+|figure\s*\d+)\b", re.IGNORECASE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
 
169
- def trim_caption_region(page: fitz.Page, crop_rect: fitz.Rect) -> fitz.Rect:
170
  """
171
- If crop includes caption text (Fig./Figure...) near the bottom,
172
- shrink ymax to exclude it.
 
 
 
 
173
  """
174
- blocks = page.get_text("blocks") # (x0,y0,x1,y1,text,block_no,block_type)
175
- if not blocks:
176
- return crop_rect
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
- # Look for caption blocks that intersect crop and are below plot area
179
- candidate_tops = []
180
- for b in blocks:
181
- x0, y0, x1, y1, text = b[0], b[1], b[2], b[3], b[4]
182
- if not text:
183
- continue
184
- t = text.strip().replace("\n", " ")
185
- if not CAP_RE.search(t):
186
- continue
187
 
188
- block_rect = fitz.Rect(x0, y0, x1, y1)
189
- if block_rect.intersects(crop_rect):
190
- # likely caption inside crop, use its top boundary
191
- candidate_tops.append(y0)
 
192
 
193
- if not candidate_tops:
194
- return crop_rect
195
 
196
- cap_top = min(candidate_tops)
197
- # Reduce crop bottom just above caption top
198
- new = fitz.Rect(crop_rect.x0, crop_rect.y0, crop_rect.x1, min(crop_rect.y1, cap_top - 2))
199
- return new if new.y1 > new.y0 + 10 else crop_rect
 
 
 
 
 
 
 
 
200
 
201
 
202
- # ----------------------------
203
- # Plot-likeness filter (reject text/diagrams)
204
- # ----------------------------
205
- def plot_likeness_score(bgr: np.ndarray) -> float:
206
  """
207
- Heuristic: plots usually have strong horizontal/vertical lines (axes, grids),
208
- and moderate edge density. Text blocks often have many tiny components and no axes.
 
 
 
209
  """
210
- gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
211
- h, w = gray.shape[:2]
212
- if h < 40 or w < 40:
213
- return 0.0
214
-
215
- # edges
216
- edges = cv2.Canny(gray, 60, 160)
217
- edge_density = float(np.mean(edges > 0))
218
-
219
- # detect long straight lines (axes/grid)
220
- lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80,
221
- minLineLength=int(min(h, w) * 0.35), maxLineGap=10)
222
-
223
-
224
- hv_lines = 0
225
- if lines is not None:
226
- for x1, y1, x2, y2 in lines[:, 0]:
227
- dx = abs(x2 - x1)
228
- dy = abs(y2 - y1)
229
- if dx > 3 * dy or dy > 3 * dx:
230
- hv_lines += 1
231
-
232
- # connected components for "textiness"
233
- _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
234
- num_labels, _, stats, _ = cv2.connectedComponentsWithStats(bw, connectivity=8)
235
- # count small-ish components (typical for dense text)
236
- small = 0
237
- for i in range(1, num_labels):
238
- area = stats[i, cv2.CC_STAT_AREA]
239
- if 10 <= area <= 120:
240
- small += 1
241
- small_density = small / (h * w / 10000.0 + 1e-6)
242
-
243
- # Score: prefer hv_lines + moderate edges; penalize extreme textiness
244
- score = (0.55 * min(1.0, hv_lines / 8.0)) + (0.35 * min(1.0, edge_density / 0.12)) - (0.20 * min(1.0, small_density / 25.0))
245
- return float(max(0.0, min(1.0, score)))
246
-
247
-
248
- def looks_like_plot(bgr: np.ndarray, threshold: float = 0.35) -> Tuple[bool, float]:
249
- s = plot_likeness_score(bgr)
250
- return (s >= threshold), s
251
-
252
-
253
- # ----------------------------
254
- # Extraction with better padding
255
- # ----------------------------
256
- def extract_plots(pdf_path: str, plot_data: list, pad: int, score_thresh: float):
257
- doc = fitz.open(pdf_path)
258
- results = []
259
-
260
- for idx, item in enumerate(plot_data):
261
- page_num = int(item.get("page", 1)) - 1
262
- if page_num < 0 or page_num >= len(doc):
263
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
- page = doc[page_num]
266
- rect = page.rect
267
- box = item.get("box_2d", None)
268
- if not box or len(box) != 4:
269
- continue
270
 
271
- # normalized 0..1000 → absolute page coords
272
- ymin = (box[0] * rect.height / 1000.0)
273
- xmin = (box[1] * rect.width / 1000.0)
274
- ymax = (box[2] * rect.height / 1000.0)
275
- xmax = (box[3] * rect.width / 1000.0)
276
-
277
- # Asymmetric padding: keep labels, avoid caption blow-up
278
- pad_left = pad * 1.4
279
- pad_right = pad * 0.9
280
- pad_top = pad * 0.9
281
- pad_bottom = pad * 0.7 # smaller bottom pad to avoid captions
282
-
283
- fitz_rect = fitz.Rect(
284
- max(0, xmin - pad_left),
285
- max(0, ymin - pad_top),
286
- min(rect.width, xmax + pad_right),
287
- min(rect.height, ymax + pad_bottom)
288
- )
289
 
290
- # Trim caption if accidentally included
291
- fitz_rect = trim_caption_region(page, fitz_rect)
292
 
293
- # Render
294
- pix = page.get_pixmap(clip=fitz_rect, dpi=300)
295
- img_path = f"plot_res_{idx}.png"
296
- pix.save(img_path)
297
 
298
- # Post-filter: reject non-plots
299
- bgr = cv2.imread(img_path)
300
- ok, score = looks_like_plot(bgr, threshold=score_thresh)
301
 
302
- if not ok:
303
- # remove saved non-plot image
304
- try:
305
- os.remove(img_path)
306
- except Exception:
307
- pass
308
- continue
 
 
 
 
 
309
 
310
- results.append({
311
- "caption": item.get("caption", f"Figure {idx}"),
312
- "page": item.get("page", 1),
313
- "path": img_path,
314
- "plot_score": round(score, 3),
315
- "plot_type": item.get("plot_type", "unknown")
316
- })
317
 
318
- doc.close()
319
- return results
 
320
 
 
 
 
321
 
322
- # ----------------------------
323
- # Streamlit UI
324
- # ----------------------------
325
- def main():
326
- st.set_page_config(layout="wide", page_title="Scientific Figure Extractor")
327
- st.title("Scientific Figure & Subplot Extractor")
 
 
 
 
 
328
 
329
- if "raw_data" not in st.session_state:
330
- st.session_state.raw_data = None
331
- if "temp_pdf" not in st.session_state:
332
- st.session_state.temp_pdf = None
 
 
 
 
 
 
333
 
334
- with st.sidebar:
335
- api_key = st.text_input("Gemini API Key", type="password")
336
  st.divider()
337
- st.subheader("Crop & Filter")
338
- crop_pad = st.slider("Padding (px-like on page coords)", 0, 80, 22)
339
- score_thresh = st.slider("Reject non-plots (higher = stricter)", 0.10, 0.80, 0.20, 0.01)
340
-
341
- uploaded_file = st.file_uploader("Upload PDF", type="pdf")
342
-
343
- if uploaded_file and api_key:
344
- if st.button("Extract All Data Figures"):
345
- with st.spinner("AI is scanning for plots and sub-panels..."):
346
- with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
347
- tmp.write(uploaded_file.getbuffer())
348
- st.session_state.temp_pdf = tmp.name
349
-
350
- client = genai.Client(api_key=api_key)
351
- model_name = get_available_model(api_key)
352
- st.session_state.raw_data = get_plot_data_from_llm(client, model_name, st.session_state.temp_pdf)
353
-
354
- # Results Display
355
- if st.session_state.raw_data and st.session_state.temp_pdf:
356
  st.divider()
357
- processed_figures = extract_plots(
358
- st.session_state.temp_pdf,
359
- st.session_state.raw_data,
360
- pad=crop_pad,
361
- score_thresh=score_thresh
362
- )
363
 
364
- st.success(f"Kept {len(processed_figures)} figures after non-plot filtering.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
- for fig in processed_figures:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  with st.container(border=True):
368
- st.markdown(f"**{fig['caption']}**")
369
- st.caption(f"Page {fig['page']} | plot_score={fig['plot_score']} | plot_type={fig['plot_type']}")
370
- st.image(fig["path"], use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
 
373
  if __name__ == "__main__":
374
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Plot extraction
3
+ Plot Extraction - Detection + Missed-Plot Recovery + Crop Verification
4
+ ==========================================================================
5
+ NO DATABASE. Just gives you back the extracted plots.
6
+
7
+ 1. DETECT - OpenCV geometry heuristic + PyMuPDF embedded-image list,
8
+ merged, paired with parent/subplot captions.
9
+ 2. RECOVER - per page, ask Gemini "list every distinct figure/plot on
10
+ this page with its bounding box" and compare against what
11
+ Step 1 already found (via IoU overlap). Anything Gemini
12
+ sees that Step 1 missed gets cropped from the full page
13
+ render and added as a recovered figure. This is the
14
+ "did we miss any plots" check.
15
+ 3. VERIFY - every crop (original AND recovered) gets a Gemini call
16
+ judging crop QUALITY: complete/not cut off, not merged
17
+ with something else, no leaked neighboring content. This
18
+ info is attached to each returned image dict as
19
+ "verification" — nothing is filtered out or stored
20
+ anywhere; you decide what to do with it.
21
+
22
+ GEMINI_API_KEY comes from os.getenv. If it's missing, steps 2 and 3 are
23
+ skipped (not silently faked) — you still get Step 1's plots back, just
24
+ without the recall check or quality verdicts.
25
+
26
+ Usage:
27
+ from plot_extraction_verified import extract_and_verify_plots, create_plot_zip
28
+
29
+ with open("paper.pdf", "rb") as f:
30
+ pdf_bytes = f.read()
31
+
32
+ plot_results, coverage_report = extract_and_verify_plots(pdf_bytes)
33
+ # plot_results: [{"caption","page","image_data":[...]}], each image
34
+ # dict has "bytes","array","subplot_label","subplot_caption","source",
35
+ # and "verification" (Gemini's crop-quality verdict, or None if skipped)
36
+
37
+ zip_bytes = create_plot_zip(plot_results) # images + metadata JSON
38
+ """
39
+ #individual llm with gpt, claude check results are fine
40
+ from __future__ import annotations
41
+
42
+ import base64
43
+ import io
44
+ import json
45
  import logging
46
+ import os
47
+ import re
48
+ import zipfile
49
+ from collections import defaultdict
50
+ from typing import Any, Dict, List, Optional, Tuple
51
 
52
+ import fitz
53
+ import numpy as np
54
  import requests
55
+ from dotenv import load_dotenv
56
+
57
+ try:
58
+ import cv2
59
+ CV2_AVAILABLE = True
60
+ except ImportError:
61
+ CV2_AVAILABLE = False
62
+ logging.warning("opencv-python not installed")
63
 
64
  log = logging.getLogger(__name__)
65
+ logging.basicConfig(level=logging.INFO)
66
 
67
+ load_dotenv()
 
 
 
 
 
 
68
 
 
 
 
69
 
70
+ def get_gemini_api_key() -> str:
71
+ """Reads GEMINI_API_KEY from the environment via os.getenv (populated
72
+ by load_dotenv() above if a .env file is present). Returns "" if it
73
+ isn't set — callers check for that rather than crashing, so the app
74
+ can still run detection-only (no verification/recovery) without a key."""
75
+ return os.getenv("GEMINI_API_KEY", "")
76
+
77
+
78
+ GEMINI_API_KEY = get_gemini_api_key()
79
+
80
+ _GEMINI_V3_FALLBACK_MODELS = [
81
+ "gemini-3.5-flash",
82
+ "gemini-3.1-pro-preview",
83
+ "gemini-3.1-flash-lite",
84
+ "gemini-3.0-pro",
85
+ "gemini-3.0-flash",
86
+ ]
87
+ _MODEL_VERSION_RE = re.compile(r"gemini-(\d+(?:\.\d+)?)")
88
 
89
 
90
+ def _gemini_model_version(model_name: str) -> float:
91
+ m = _MODEL_VERSION_RE.search(model_name)
92
+ return float(m.group(1)) if m else 0.0
 
93
 
 
 
94
 
95
+ def fetch_gemini_models_v3_plus(api_key: str) -> List[str]:
96
+ """Live-fetch the account's Gemini models, keep only 3.0+, sorted highest first.
97
+ Falls back to a static list if the key is missing or the call fails —
98
+ this is what powers the sidebar model dropdown."""
99
+ if not api_key:
100
+ return _GEMINI_V3_FALLBACK_MODELS
101
  try:
102
  resp = requests.get(
103
  f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}",
104
+ timeout=10,
105
  )
106
  if not resp.ok:
107
+ return _GEMINI_V3_FALLBACK_MODELS
 
108
  models = resp.json().get("models", [])
109
+ candidates = [
110
+ m["name"].replace("models/", "") for m in models
111
+ if "generateContent" in m.get("supportedGenerationMethods", [])
 
 
 
 
112
  ]
113
+ v3_plus = sorted(
114
+ [n for n in candidates if _gemini_model_version(n) >= 3.0],
115
+ key=_gemini_model_version, reverse=True,
116
+ )
117
+ return v3_plus or _GEMINI_V3_FALLBACK_MODELS
 
 
 
 
118
  except Exception as e:
119
+ log.warning(f"Gemini model list fetch failed ({e}) - using fallback.")
120
+ return _GEMINI_V3_FALLBACK_MODELS
121
+
122
+
123
+ def get_best_gemini_v3_model(api_key: str) -> str:
124
+ models = fetch_gemini_models_v3_plus(api_key)
125
+ return models[0] if models else _GEMINI_V3_FALLBACK_MODELS[0]
126
+
127
+
128
+ GEMINI_MODEL = get_best_gemini_v3_model(GEMINI_API_KEY)
129
+
130
+ # ─────────────────────────────────────────────────────────────────────────────
131
+ # GPT / CLAUDE CONFIG — optional, for side-by-side verification comparison
132
+ # ─────────────────────────────────────────────────────────────────────────────
133
+
134
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
135
+ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
136
+
137
+ GPT_API_URL = "https://api.openai.com/v1/chat/completions"
138
+ CLAUDE_API_URL = "https://api.anthropic.com/v1/messages"
139
+ CLAUDE_API_VERSION = "2023-06-01"
140
+
141
+ GPT_FALLBACK_MODELS = ["gpt-4o", "gpt-4o-mini"]
142
+ CLAUDE_FALLBACK_MODELS = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5-20251001"]
143
+
144
+ PLOT_DPI = 300
145
+ PADDING = 30
146
+
147
+ _CAPTION_RE = re.compile(r"^(Fig\.?\s*\d+|Figure\s*\d+)\b", re.IGNORECASE)
148
+ # Lowercase only: real subplot panel labels are conventionally lowercase
149
+ # "(a)", "(b)"... Uppercase parenthetical single letters like "(B)", "(O)"
150
+ # are almost always garbled legend-marker glyphs (filled square/triangle/
151
+ # circle symbols that lost their font mapping during PDF text extraction),
152
+ # not real panel labels — matching those uppercase caused false-positive
153
+ # subplot splitting on ordinary single-panel figures whose legend uses
154
+ # symbol markers.
155
+ _SUBPLOT_MARKER_RE = re.compile(r"^\(?([a-h])\)?\.?$")
156
+ _PANEL_SPLIT_RE = re.compile(r"\(([a-h])\)")
157
+
158
+
159
+ def _plot_page_image(page):
160
+ pix = page.get_pixmap(matrix=fitz.Matrix(PLOT_DPI / 72, PLOT_DPI / 72))
161
+ img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, 3)
162
+ return cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
163
+
164
+
165
+ def _is_valid_plot_geometry(binary_crop):
166
+ h, w = binary_crop.shape
167
+ if h < 100 or w < 100:
168
+ return False
169
+ ink_density = cv2.countNonZero(binary_crop) / float(w * h)
170
+ if ink_density > 0.35:
171
+ return False
172
+ h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(10, w // 4), 1))
173
+ v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(10, h // 4)))
174
+ has_h = cv2.countNonZero(cv2.erode(binary_crop, h_kernel, iterations=1)) > 0
175
+ has_v = cv2.countNonZero(cv2.erode(binary_crop, v_kernel, iterations=1)) > 0
176
+ return has_h or has_v
177
+
178
+
179
+ def _merge_plot_boxes(rects):
180
+ if not rects:
181
+ return []
182
+ rects = sorted(rects, key=lambda r: r[2] * r[3], reverse=True)
183
+ merged = []
184
+ for r in rects:
185
+ rx, ry, rw, rh = r
186
+ if not any(
187
+ rx >= m[0] - 15 and ry >= m[1] - 15 and
188
+ rx + rw <= m[0] + m[2] + 15 and
189
+ ry + rh <= m[1] + m[3] + 15
190
+ for m in merged
191
+ ):
192
+ merged.append(r)
193
+ return merged
194
+
195
+
196
+ def _extract_embedded_image_boxes(page):
197
+ scale = PLOT_DPI / 72
198
+ boxes = []
199
+ try:
200
+ for img_info in page.get_images(full=True):
201
+ xref = img_info[0]
202
+ try:
203
+ rects = page.get_image_rects(xref)
204
+ except Exception:
205
+ continue
206
+ for rect in rects:
207
+ x, y = int(rect.x0 * scale), int(rect.y0 * scale)
208
+ w, h = int(rect.width * scale), int(rect.height * scale)
209
+ if w < 60 or h < 60:
210
+ continue
211
+ boxes.append((x, y, w, h))
212
+ except Exception as e:
213
+ log.warning(f"Embedded image box extraction failed: {e}")
214
+ return boxes
215
 
216
 
217
+ def _find_parent_caption(blocks, cx, cy, cw, ch, page_h):
218
  """
219
+ Nearest 'Fig./Figure N' caption below the plot region — but ONLY among
220
+ captions that horizontally overlap the plot's column (or are wide
221
+ enough to plausibly span the full page width, which is common for
222
+ captions that sit below both columns). Without this, a two-column
223
+ layout can pick up the neighboring column's caption just because it's
224
+ vertically closer than the plot's own caption — this was the cause of
225
+ Fig. 4's plot getting grouped under Fig. 2's caption when both sat at
226
+ the same page height in different columns.
227
  """
228
+ best_caption = ""
229
+ min_dist = float("inf")
230
+ plot_x1, plot_x2 = cx, cx + cw
231
+ plot_width = max(1, cw)
232
+ for b in blocks:
233
+ if len(b) < 5:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  continue
235
+ text = (b[4] or "").strip()
236
+ if _CAPTION_RE.match(text):
237
+ cap_x1 = b[0] * (PLOT_DPI / 72)
238
+ cap_x2 = b[2] * (PLOT_DPI / 72)
239
+ cap_y = b[1] * (PLOT_DPI / 72)
240
+ overlap = max(0, min(plot_x2, cap_x2) - max(plot_x1, cap_x1))
241
+ caption_width = max(1, cap_x2 - cap_x1)
242
+ spans_wide = caption_width > plot_width * 2.2 # likely a full-width caption block
243
+ if overlap <= 0 and not spans_wide:
244
+ continue # caption sits in a different column — not this plot's caption
245
+ dist = cap_y - (cy + ch)
246
+ if -20 < dist < (page_h * 0.4) and abs(dist) < abs(min_dist):
247
+ best_caption = text.replace("\n", " ")
248
+ min_dist = dist
249
+ return best_caption
250
+
251
+
252
+ def _sort_reading_order(boxes):
253
+ if not boxes:
254
+ return []
255
+ boxes_sorted = sorted(boxes, key=lambda b: b[1])
256
+ rows = []
257
+ current_row = [boxes_sorted[0]]
258
+ row_y, row_h = boxes_sorted[0][1], boxes_sorted[0][3]
259
+ for b in boxes_sorted[1:]:
260
+ if b[1] < row_y + row_h * 0.6:
261
+ current_row.append(b)
262
+ else:
263
+ rows.append(current_row)
264
+ current_row = [b]
265
+ row_y, row_h = b[1], b[3]
266
+ rows.append(current_row)
267
+ ordered = []
268
+ for row in rows:
269
+ ordered.extend(sorted(row, key=lambda b: b[0]))
270
+ return ordered
271
+
272
+
273
+ def _find_subplot_label(blocks, box):
274
+ x, y, w, h = box
275
+ search_pad = 40
276
+ for b in blocks:
277
+ if len(b) < 5:
278
+ continue
279
+ text = (b[4] or "").strip()
280
+ m = _SUBPLOT_MARKER_RE.match(text)
281
+ if not m:
282
+ continue
283
+ bx = b[0] * (PLOT_DPI / 72)
284
+ by = b[1] * (PLOT_DPI / 72)
285
+ if (x - search_pad) <= bx <= (x + w) and (y - search_pad) <= by <= (y + h * 0.5):
286
+ return f"({m.group(1).lower()})"
287
+ return None
288
 
289
 
290
+ def _split_parent_caption_into_panels(caption_text):
291
  """
292
+ Split '(a) text (b) text (c) text' into {'a': 'text', 'b': 'text', ...}.
293
+ Requires the matched letters to form a strict, contiguous run starting
294
+ at 'a' (a,b / a,b,c / ...) — real multi-panel captions always label
295
+ panels this way. Any other pattern (e.g. a lone 'e', or 'b' and 'e'
296
+ with no 'a'/'c'/'d' between them) is almost certainly not real subplot
297
+ lettering and is rejected rather than guessed at.
298
  """
299
+ markers = list(_PANEL_SPLIT_RE.finditer(caption_text))
300
+ if len(markers) < 2:
301
+ return {}
302
+ letters = [m.group(1) for m in markers]
303
+ if letters[0] != "a":
304
+ return {}
305
+ for i in range(1, len(letters)):
306
+ if ord(letters[i]) != ord(letters[i - 1]) + 1:
307
+ return {}
308
+ panels = {}
309
+ for i, m in enumerate(markers):
310
+ letter = m.group(1)
311
+ start = m.end()
312
+ end = markers[i + 1].start() if i + 1 < len(markers) else len(caption_text)
313
+ panels[letter] = caption_text[start:end].strip(" .,:;")
314
+ return panels
315
+
316
+
317
+ MISSED_PLOT_CHECK_PROMPT = (
318
+ "This is a full page from a scientific PDF, rendered as an image. Identify EVERY "
319
+ "distinct figure, plot, chart, graph, or diagram visible on this page. Ignore plain "
320
+ "tables of numbers and body-text paragraphs - only visual figures.\n\n"
321
+ "For each one, give its approximate bounding box on the page and a short description. "
322
+ "Bounding box format: [y_min, x_min, y_max, x_max], each an integer 0-1000 representing "
323
+ "position as a fraction of page height/width (0 = top/left edge, 1000 = bottom/right edge).\n\n"
324
+ "Respond ONLY with a JSON array, no markdown, no explanation:\n"
325
+ "[{\"box_2d\": [y_min, x_min, y_max, x_max], \"description\": \"short description\"}]\n"
326
+ "If there are no figures on this page, respond with exactly: []"
327
+ )
328
+
329
+ IOU_OVERLAP_THRESHOLD = 0.30
330
+
331
+
332
+ def _box_iou(box_a, box_b):
333
+ ax1, ay1, ax2, ay2 = box_a[0], box_a[1], box_a[0] + box_a[2], box_a[1] + box_a[3]
334
+ bx1, by1, bx2, by2 = box_b[0], box_b[1], box_b[0] + box_b[2], box_b[1] + box_b[3]
335
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
336
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
337
+ iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
338
+ inter = iw * ih
339
+ area_a = box_a[2] * box_a[3]
340
+ area_b = box_b[2] * box_b[3]
341
+ union = area_a + area_b - inter
342
+ return inter / union if union > 0 else 0.0
343
+
344
+
345
+ def _gemini_box_to_pixels(box_2d, page_w, page_h):
346
+ y_min, x_min, y_max, x_max = box_2d
347
+ x = int(x_min / 1000 * page_w)
348
+ y = int(y_min / 1000 * page_h)
349
+ w = int((x_max - x_min) / 1000 * page_w)
350
+ h = int((y_max - y_min) / 1000 * page_h)
351
+ return (max(0, x), max(0, y), max(1, w), max(1, h))
352
+
353
+
354
+ def find_all_figures_on_page_gemini(page_png_bytes):
355
+ if not GEMINI_API_KEY:
356
+ return []
357
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}"
358
+ encoded = base64.b64encode(page_png_bytes).decode("utf-8")
359
+ payload = {
360
+ "contents": [{
361
+ "parts": [
362
+ {"text": MISSED_PLOT_CHECK_PROMPT},
363
+ {"inlineData": {"mimeType": "image/png", "data": encoded}},
364
+ ]
365
+ }],
366
+ "generationConfig": {"temperature": 0, "responseMimeType": "application/json"},
367
+ }
368
+ try:
369
+ resp = requests.post(url, json=payload, timeout=90)
370
+ if not resp.ok:
371
+ log.warning(f"Gemini page scan HTTP {resp.status_code}: {resp.text[:300]}")
372
+ return []
373
+ data = resp.json()
374
+ candidates = data.get("candidates", [])
375
+ if not candidates:
376
+ return []
377
+ parts = candidates[0].get("content", {}).get("parts", [])
378
+ raw = "".join(p.get("text", "") for p in parts).strip()
379
+ cleaned = re.sub(r"^```(?:json)?\s*", "", raw)
380
+ cleaned = re.sub(r"\s*```$", "", cleaned).strip()
381
+ entries = json.loads(cleaned)
382
+ return entries if isinstance(entries, list) else []
383
+ except Exception as e:
384
+ log.warning(f"Gemini page scan failed: {e}")
385
+ return []
386
+
387
+
388
+ CROP_VERIFICATION_PROMPT = (
389
+ "You are checking whether this image is a PROPERLY CROPPED scientific figure/plot "
390
+ "extracted automatically from a PDF page. You are judging the CROP QUALITY - the "
391
+ "framing and boundaries - not trying to read data values off the plot.\n\n"
392
+ "Check specifically:\n"
393
+ "1. Is this a complete, single plot/graph - not a photo, not a block of text, and "
394
+ "not two or more unrelated figures merged together into one crop?\n"
395
+ "2. Are the axes, axis labels, tick numbers, and legend (if present) FULLY visible "
396
+ "and not cut off at any edge of the crop?\n"
397
+ "3. Does the crop include excess unrelated content - page margin whitespace far "
398
+ "beyond the plot, stray paragraph text, or part of a neighboring figure/table "
399
+ "bleeding into the frame?\n\n"
400
+ "Respond ONLY with this JSON object, no markdown, no explanation:\n"
401
+ "{\n"
402
+ ' "is_valid_plot": true/false,\n'
403
+ ' "is_complete_not_cutoff": true/false,\n'
404
+ ' "has_excess_content": true/false,\n'
405
+ ' "recommended_action": "keep" | "recrop" | "discard",\n'
406
+ ' "issues": ["short description of each problem found, empty list if none"],\n'
407
+ ' "confidence": "high" | "medium" | "low"\n'
408
+ "}"
409
+ )
410
+
411
+
412
+ def verify_crop_with_gemini(image_bytes):
413
+ if not GEMINI_API_KEY:
414
+ return {
415
+ "verified": False, "recommended_action": "unverified",
416
+ "is_valid_plot": None, "is_complete_not_cutoff": None,
417
+ "has_excess_content": None, "issues": ["GEMINI_API_KEY not set"],
418
+ "confidence": "none", "raw_response": "",
419
+ }
420
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}"
421
+ encoded = base64.b64encode(image_bytes).decode("utf-8")
422
+ payload = {
423
+ "contents": [{
424
+ "parts": [
425
+ {"text": CROP_VERIFICATION_PROMPT},
426
+ {"inlineData": {"mimeType": "image/png", "data": encoded}},
427
+ ]
428
+ }],
429
+ "generationConfig": {"temperature": 0, "responseMimeType": "application/json"},
430
+ }
431
+ try:
432
+ resp = requests.post(url, json=payload, timeout=60)
433
+ if not resp.ok:
434
+ log.warning(f"Gemini crop verification HTTP {resp.status_code}: {resp.text[:300]}")
435
+ return {
436
+ "verified": False, "recommended_action": "unverified",
437
+ "is_valid_plot": None, "is_complete_not_cutoff": None,
438
+ "has_excess_content": None,
439
+ "issues": [f"HTTP {resp.status_code}"], "confidence": "none",
440
+ "raw_response": resp.text[:500],
441
+ }
442
+ data = resp.json()
443
+ candidates = data.get("candidates", [])
444
+ if not candidates:
445
+ raise ValueError("no candidates in Gemini response")
446
+ parts = candidates[0].get("content", {}).get("parts", [])
447
+ raw = "".join(p.get("text", "") for p in parts).strip()
448
+ cleaned = re.sub(r"^```(?:json)?\s*", "", raw)
449
+ cleaned = re.sub(r"\s*```$", "", cleaned).strip()
450
+ parsed = json.loads(cleaned)
451
+ parsed["verified"] = True
452
+ parsed["raw_response"] = raw
453
+ parsed.setdefault("recommended_action", "discard")
454
+ return parsed
455
+ except Exception as e:
456
+ log.warning(f"Gemini crop verification failed: {e}")
457
+ return {
458
+ "verified": False, "recommended_action": "unverified",
459
+ "is_valid_plot": None, "is_complete_not_cutoff": None,
460
+ "has_excess_content": None,
461
+ "issues": [f"error: {e}"], "confidence": "none", "raw_response": "",
462
+ }
463
+
464
+
465
+ _UNVERIFIED_TEMPLATE = {
466
+ "verified": False, "recommended_action": "unverified",
467
+ "is_valid_plot": None, "is_complete_not_cutoff": None,
468
+ "has_excess_content": None, "confidence": "none", "raw_response": "",
469
+ }
470
+
471
+
472
+ def verify_crop_with_gpt(image_bytes: bytes, model: str = "gpt-4o") -> Dict[str, Any]:
473
+ """Same crop-quality check as verify_crop_with_gemini, via GPT vision — lets
474
+ you compare verdicts across models rather than trusting Gemini alone."""
475
+ if not OPENAI_API_KEY:
476
+ return {**_UNVERIFIED_TEMPLATE, "issues": ["OPENAI_API_KEY not set"]}
477
+ encoded = base64.b64encode(image_bytes).decode("utf-8")
478
+ headers = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"}
479
+ payload = {
480
+ "model": model,
481
+ "temperature": 0,
482
+ "messages": [{
483
+ "role": "user",
484
+ "content": [
485
+ {"type": "text", "text": CROP_VERIFICATION_PROMPT},
486
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encoded}"}},
487
+ ],
488
+ }],
489
+ }
490
+ try:
491
+ resp = requests.post(GPT_API_URL, headers=headers, json=payload, timeout=60)
492
+ if not resp.ok:
493
+ return {**_UNVERIFIED_TEMPLATE, "issues": [f"HTTP {resp.status_code}"],
494
+ "raw_response": resp.text[:500]}
495
+ raw = resp.json()["choices"][0]["message"]["content"].strip()
496
+ cleaned = re.sub(r"^```(?:json)?\s*", "", raw)
497
+ cleaned = re.sub(r"\s*```$", "", cleaned).strip()
498
+ parsed = json.loads(cleaned)
499
+ parsed["verified"] = True
500
+ parsed["raw_response"] = raw
501
+ parsed.setdefault("recommended_action", "discard")
502
+ return parsed
503
+ except Exception as e:
504
+ log.warning(f"GPT crop verification failed: {e}")
505
+ return {**_UNVERIFIED_TEMPLATE, "issues": [f"error: {e}"]}
506
+
507
+
508
+ def verify_crop_with_claude(image_bytes: bytes, model: str = "claude-sonnet-4-6") -> Dict[str, Any]:
509
+ """Same crop-quality check via Claude vision."""
510
+ if not ANTHROPIC_API_KEY:
511
+ return {**_UNVERIFIED_TEMPLATE, "issues": ["ANTHROPIC_API_KEY not set"]}
512
+ encoded = base64.b64encode(image_bytes).decode("utf-8")
513
+ headers = {
514
+ "x-api-key": ANTHROPIC_API_KEY,
515
+ "anthropic-version": CLAUDE_API_VERSION,
516
+ "content-type": "application/json",
517
+ }
518
+ payload = {
519
+ "model": model,
520
+ "max_tokens": 512,
521
+ "messages": [{
522
+ "role": "user",
523
+ "content": [
524
+ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": encoded}},
525
+ {"type": "text", "text": CROP_VERIFICATION_PROMPT},
526
+ ],
527
+ }],
528
+ }
529
+ try:
530
+ resp = requests.post(CLAUDE_API_URL, headers=headers, json=payload, timeout=60)
531
+ if not resp.ok:
532
+ return {**_UNVERIFIED_TEMPLATE, "issues": [f"HTTP {resp.status_code}"],
533
+ "raw_response": resp.text[:500]}
534
+ content_blocks = resp.json().get("content", [])
535
+ raw = "".join(b.get("text", "") for b in content_blocks if b.get("type") == "text").strip()
536
+ cleaned = re.sub(r"^```(?:json)?\s*", "", raw)
537
+ cleaned = re.sub(r"\s*```$", "", cleaned).strip()
538
+ parsed = json.loads(cleaned)
539
+ parsed["verified"] = True
540
+ parsed["raw_response"] = raw
541
+ parsed.setdefault("recommended_action", "discard")
542
+ return parsed
543
+ except Exception as e:
544
+ log.warning(f"Claude crop verification failed: {e}")
545
+ return {**_UNVERIFIED_TEMPLATE, "issues": [f"error: {e}"]}
546
 
 
 
 
 
 
 
 
 
 
547
 
548
+ _VERIFY_ENGINES = {
549
+ "gemini": lambda img_bytes, model: verify_crop_with_gemini(img_bytes),
550
+ "gpt": verify_crop_with_gpt,
551
+ "claude": verify_crop_with_claude,
552
+ }
553
 
 
 
554
 
555
+ def _majority_action(verdicts: Dict[str, Dict[str, Any]]) -> str:
556
+ """Combine multiple engines' recommended_action into one decision:
557
+ majority vote among engines that actually returned a verdict; 'discard'
558
+ wins ties (safer default than accidentally keeping a bad crop)."""
559
+ actions = [v["recommended_action"] for v in verdicts.values() if v.get("verified")]
560
+ if not actions:
561
+ return "unverified"
562
+ counts: Dict[str, int] = {}
563
+ for a in actions:
564
+ counts[a] = counts.get(a, 0) + 1
565
+ best = max(counts.items(), key=lambda kv: (kv[1], kv[0] == "discard"))
566
+ return best[0]
567
 
568
 
569
+ def verify_crop_multi(image_bytes: bytes, engines: List[str], models: Dict[str, str]) -> Dict[str, Any]:
 
 
 
570
  """
571
+ Runs the crop-quality check across every engine in `engines` (subset of
572
+ "gemini"/"gpt"/"claude"), each with its own model from `models`.
573
+ Returns {"by_engine": {engine: verdict, ...}, "majority_action": str,
574
+ "verified": bool} — "verified" is True if at least one engine actually
575
+ returned a verdict (vs. all being skipped for missing keys).
576
  """
577
+ by_engine: Dict[str, Dict[str, Any]] = {}
578
+ for engine in engines:
579
+ fn = _VERIFY_ENGINES.get(engine)
580
+ if fn is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  continue
582
+ by_engine[engine] = fn(image_bytes, models.get(engine, ""))
583
+ majority = _majority_action(by_engine)
584
+ any_verified = any(v.get("verified") for v in by_engine.values())
585
+ return {"by_engine": by_engine, "majority_action": majority, "verified": any_verified}
586
+
587
+
588
+ # ─────────────────────────────────────────────────────────────────────────────
589
+ # GEMINI-BASED RE-CROP FOR MERGED PLOTS
590
+ # For crops the verification step already flagged "recrop" (typically:
591
+ # two+ distinct charts merged into one image because a tight subplot grid's
592
+ # gutter got bridged by the contour-detection dilation step). Rather than
593
+ # guessing the split point from pixel density — tested and found unreliable,
594
+ # since real single charts routinely have large legitimately-blank regions
595
+ # that look identical to a real gutter by that measure — this asks Gemini
596
+ # to look at the specific flagged image and report where the actual chart
597
+ # boundaries are, since it can visually tell "there are two charts here"
598
+ # with far more reliability than a density heuristic ever could.
599
+ # ─────────────────────────────────────────────────────────────────────────────
600
+
601
+ RESPLIT_PROMPT = (
602
+ "This image may contain TWO OR MORE separate, distinct charts/plots that "
603
+ "were accidentally cropped together into one image (e.g. two adjacent "
604
+ "subplots from a grid, merged because their gutter was too narrow).\n\n"
605
+ "Look carefully. If this image genuinely contains only ONE chart, respond "
606
+ "with exactly: {\"is_merged\": false, \"charts\": []}\n\n"
607
+ "If it contains multiple distinct charts, respond with the bounding box of "
608
+ "EACH individual chart (not the whole image — each chart's own axes/plot "
609
+ "area), in normalized 0-1000 coordinates relative to this image "
610
+ "([y_min, x_min, y_max, x_max], 0=top/left, 1000=bottom/right):\n"
611
+ "{\"is_merged\": true, \"charts\": [{\"box_2d\": [y_min, x_min, y_max, x_max]}, ...]}\n\n"
612
+ "Respond ONLY with the JSON object, no markdown, no explanation."
613
+ )
614
+
615
+
616
+ def resplit_merged_crop(image_bytes: bytes) -> Optional[List[bytes]]:
617
+ """
618
+ Sends a single flagged crop to Gemini asking whether it's actually
619
+ multiple charts merged together, and if so, where each one is.
620
+ Returns None if Gemini says it's one chart (or the call fails — fail
621
+ closed, don't destroy the original crop on an API error), or a list of
622
+ 2+ PNG-encoded sub-crop byte strings if a genuine split was found.
623
+ """
624
+ if not GEMINI_API_KEY or not CV2_AVAILABLE:
625
+ return None
626
+
627
+ nparr = np.frombuffer(image_bytes, np.uint8)
628
+ img_bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
629
+ if img_bgr is None:
630
+ return None
631
+ h, w = img_bgr.shape[:2]
632
+
633
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}"
634
+ encoded = base64.b64encode(image_bytes).decode("utf-8")
635
+ payload = {
636
+ "contents": [{
637
+ "parts": [
638
+ {"text": RESPLIT_PROMPT},
639
+ {"inlineData": {"mimeType": "image/png", "data": encoded}},
640
+ ]
641
+ }],
642
+ "generationConfig": {"temperature": 0, "responseMimeType": "application/json"},
643
+ }
644
+ try:
645
+ resp = requests.post(url, json=payload, timeout=60)
646
+ if not resp.ok:
647
+ log.warning(f"Re-split check HTTP {resp.status_code}: {resp.text[:300]}")
648
+ return None
649
+ data = resp.json()
650
+ candidates = data.get("candidates", [])
651
+ if not candidates:
652
+ return None
653
+ parts = candidates[0].get("content", {}).get("parts", [])
654
+ raw = "".join(p.get("text", "") for p in parts).strip()
655
+ cleaned = re.sub(r"^```(?:json)?\s*", "", raw)
656
+ cleaned = re.sub(r"\s*```$", "", cleaned).strip()
657
+ parsed = json.loads(cleaned)
658
+
659
+ if not parsed.get("is_merged") or len(parsed.get("charts", [])) < 2:
660
+ return None
661
+
662
+ sub_crops: List[bytes] = []
663
+ for chart in parsed["charts"]:
664
+ box_2d = chart.get("box_2d")
665
+ if not box_2d or len(box_2d) != 4:
666
+ continue
667
+ y_min, x_min, y_max, x_max = box_2d
668
+ x1, y1 = int(x_min / 1000 * w), int(y_min / 1000 * h)
669
+ x2, y2 = int(x_max / 1000 * w), int(y_max / 1000 * h)
670
+ x1, y1 = max(0, x1), max(0, y1)
671
+ x2, y2 = min(w, x2), min(h, y2)
672
+ if x2 - x1 < 30 or y2 - y1 < 30:
673
+ continue
674
+ sub = img_bgr[y1:y2, x1:x2]
675
+ ok, buf = cv2.imencode(".png", sub)
676
+ if ok:
677
+ sub_crops.append(buf.tobytes())
678
+
679
+ return sub_crops if len(sub_crops) >= 2 else None
680
+ except Exception as e:
681
+ log.warning(f"Re-split check failed: {e}")
682
+ return None
683
+
684
+
685
+ def extract_plot_images(pdf_bytes, check_missed_plots=True):
686
+ if not CV2_AVAILABLE:
687
+ log.warning("Plot extraction skipped - opencv-python not installed.")
688
+ return [], {"pages": [], "total_detected": 0, "total_recovered": 0, "gemini_check_ran": False}
689
+
690
+ page_groups = defaultdict(list)
691
+ page_images = {}
692
+ page_blocks = {}
693
+ coverage_pages = []
694
+ gemini_check_ran = False
695
+
696
+ with fitz.open(stream=pdf_bytes, filetype="pdf") as pdf_doc:
697
+ for page_num, page in enumerate(pdf_doc, start=1):
698
+ img_bgr = _plot_page_image(page)
699
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
700
+ _, binary = cv2.threshold(gray, 225, 255, cv2.THRESH_BINARY_INV)
701
+ kernel = np.ones((10, 10), np.uint8)
702
+ dilated = cv2.dilate(binary, kernel, iterations=1)
703
+ contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
704
+
705
+ page_h, page_w = gray.shape
706
+ geometry_candidates = []
707
+ for cnt in contours:
708
+ x, y, w, h = cv2.boundingRect(cnt)
709
+ if 0.03 < (w * h) / float(page_w * page_h) < 0.8:
710
+ if _is_valid_plot_geometry(binary[y:y + h, x:x + w]):
711
+ # Split back apart if dilation bridged 2+ adjacent
712
+ # subplots (tight NxM grid) into one contour.
713
+ geometry_candidates.append((x, y, w, h))
714
+
715
+ embedded_candidates = [
716
+ (x, y, w, h) for (x, y, w, h) in _extract_embedded_image_boxes(page)
717
+ if 0.01 < (w * h) / float(page_w * page_h) < 0.9
718
+ ]
719
+
720
+ detected_rects = _merge_plot_boxes(geometry_candidates + embedded_candidates)
721
+ blocks = page.get_text("blocks")
722
+ page_images[page_num] = img_bgr
723
+ page_blocks[page_num] = blocks
724
+
725
+ gemini_reported_count = 0
726
+ recovered_count = 0
727
+ recovered_boxes = []
728
+
729
+ if check_missed_plots and GEMINI_API_KEY:
730
+ gemini_check_ran = True
731
+ ok, page_buf = cv2.imencode(".png", img_bgr)
732
+ if ok:
733
+ gemini_figures = find_all_figures_on_page_gemini(page_buf.tobytes())
734
+ gemini_reported_count = len(gemini_figures)
735
+ for fig in gemini_figures:
736
+ box_2d = fig.get("box_2d")
737
+ if not box_2d or len(box_2d) != 4:
738
+ continue
739
+ g_box = _gemini_box_to_pixels(box_2d, page_w, page_h)
740
+ # Compare against BOTH locally-detected boxes AND
741
+ # recovered boxes already accepted this page — Gemini
742
+ # can list the same physical figure more than once
743
+ # (e.g. once per subplot with imprecise/overlapping
744
+ # coordinate estimates) with each individual box's
745
+ # IoU against the *original* detected set staying
746
+ # below threshold even though two recovered boxes
747
+ # are themselves near-duplicates of each other.
748
+ already_covered = detected_rects + recovered_boxes
749
+ best_iou = max(
750
+ (_box_iou(g_box, d_box) for d_box in already_covered),
751
+ default=0.0,
752
+ )
753
+ if best_iou < IOU_OVERLAP_THRESHOLD:
754
+ recovered_boxes.append(g_box)
755
+ recovered_count += 1
756
+ log.info(f"Page {page_num}: recovering figure Gemini found but "
757
+ f"OpenCV/embedded missed - {fig.get('description', '')!r}")
758
+
759
+ # Final pass: merge/dedupe the combined set (local + recovered)
760
+ # together, since containment/near-duplicate boxes can still
761
+ # slip through the two checks above when a recovered box fully
762
+ # contains (rather than closely overlaps) a locally-detected one,
763
+ # or vice versa.
764
+ source_by_box_key: Dict[Tuple[int, int, int, int], str] = {}
765
+ for box in detected_rects:
766
+ source_by_box_key[box] = "opencv_or_embedded"
767
+ for box in recovered_boxes:
768
+ source_by_box_key.setdefault(box, "gemini_recovery")
769
+ deduped_page_boxes = _merge_plot_boxes(list(source_by_box_key.keys()))
770
+
771
+ coverage_pages.append({
772
+ "page": page_num,
773
+ "detected": len(detected_rects),
774
+ "gemini_reported": gemini_reported_count,
775
+ "recovered": recovered_count,
776
+ })
777
+
778
+ all_boxes_with_source = [
779
+ (box, source_by_box_key.get(box, "opencv_or_embedded"))
780
+ for box in deduped_page_boxes
781
+ ]
782
+
783
+ for box, source in all_boxes_with_source:
784
+ cx, cy, cw, ch = box
785
+ parent_caption = _find_parent_caption(blocks, cx, cy, cw, ch, page_h)
786
+ if not parent_caption:
787
+ parent_caption = f"Figure on Page {page_num} (Unlabeled)"
788
+ page_groups[page_num].append((box, parent_caption, source))
789
+
790
+ grouped_data = defaultdict(lambda: {"page": 0, "image_data": []})
791
+
792
+ for page_num, entries in page_groups.items():
793
+ blocks = page_blocks[page_num]
794
+ img_bgr = page_images[page_num]
795
+ page_h, page_w = img_bgr.shape[:2]
796
+
797
+ by_caption = defaultdict(list)
798
+ for box, caption, source in entries:
799
+ by_caption[caption].append((box, source))
800
+
801
+ for caption, box_source_pairs in by_caption.items():
802
+ ordered = _sort_reading_order([b for b, _ in box_source_pairs])
803
+ source_by_box = {b: s for b, s in box_source_pairs}
804
+ panel_text_by_letter = _split_parent_caption_into_panels(caption)
805
+ letters_in_order = sorted(panel_text_by_letter.keys())
806
+ # Position-based fallback (no explicit per-region '(a)' text found)
807
+ # is only trustworthy when the number of detected regions exactly
808
+ # matches the number of caption panels. If detection produced too
809
+ # many regions (e.g. one subplot's grid lines got split into two
810
+ # boxes, or a near-duplicate slipped through dedup) or too few,
811
+ # blindly zipping by position silently mis-assigns or duplicates
812
+ # letters — safer to leave those extras unlabeled so it's visibly
813
+ # obvious something needs a manual look, rather than confidently
814
+ # wrong.
815
+ counts_match = len(ordered) == len(letters_in_order)
816
+ if panel_text_by_letter and not counts_match:
817
+ log.warning(
818
+ f"Caption panel count mismatch for {caption!r}: "
819
+ f"{len(ordered)} region(s) detected vs {len(letters_in_order)} "
820
+ f"panel letter(s) in caption — skipping position-based subplot "
821
+ f"assignment for regions with no explicit per-region marker."
822
+ )
823
+
824
+ for i, box in enumerate(ordered):
825
+ cx, cy, cw, ch = box
826
+ source = source_by_box.get(box, "opencv_or_embedded")
827
+
828
+ label = _find_subplot_label(blocks, box)
829
+ subplot_caption = None
830
+ if label:
831
+ letter = label.strip("()").lower()
832
+ subplot_caption = panel_text_by_letter.get(letter)
833
+ elif panel_text_by_letter and counts_match:
834
+ letter = letters_in_order[i]
835
+ label = f"({letter})"
836
+ subplot_caption = panel_text_by_letter[letter]
837
+
838
+ x1, y1 = max(0, cx - PADDING), max(0, cy - PADDING)
839
+ x2, y2 = min(page_w, cx + cw + PADDING), min(page_h, cy + ch + PADDING)
840
+ crop = img_bgr[int(y1):int(y2), int(x1):int(x2)]
841
+ if crop.size == 0:
842
+ continue
843
+ ok, buffer = cv2.imencode(".png", crop)
844
+ if not ok:
845
+ continue
846
+
847
+ fname = f"pg{page_num}_{cx}_{cy}.png"
848
+ grouped_data[caption]["page"] = page_num
849
+ grouped_data[caption]["image_data"].append({
850
+ "filename": fname,
851
+ "bytes": buffer.tobytes(),
852
+ "array": crop,
853
+ "subplot_label": label,
854
+ "subplot_caption": subplot_caption,
855
+ "source": source,
856
+ })
857
+
858
+ plot_results = [
859
+ {"caption": k, "page": v["page"], "image_data": v["image_data"]}
860
+ for k, v in grouped_data.items()
861
+ ]
862
+
863
+ total_detected = sum(p["detected"] for p in coverage_pages)
864
+ total_recovered = sum(p["recovered"] for p in coverage_pages)
865
+ coverage_report = {
866
+ "pages": coverage_pages,
867
+ "total_detected": total_detected,
868
+ "total_recovered": total_recovered,
869
+ "gemini_check_ran": gemini_check_ran,
870
+ }
871
+ return plot_results, coverage_report
872
+
873
+
874
+ def create_plot_zip(results, include_json=True):
875
+ buf = io.BytesIO()
876
+ with zipfile.ZipFile(buf, "w") as z:
877
+ if include_json:
878
+ json_data = [
879
+ {
880
+ "caption": r["caption"], "page": r["page"], "image_count": len(r["image_data"]),
881
+ "images": [
882
+ {"filename": img["filename"], "subplot_label": img.get("subplot_label"),
883
+ "subplot_caption": img.get("subplot_caption"), "source": img.get("source"),
884
+ "verification": img.get("verification")}
885
+ for img in r["image_data"]
886
+ ],
887
+ }
888
+ for r in results
889
+ ]
890
+ z.writestr("plot_data.json", json.dumps(json_data, indent=2))
891
+ for item in results:
892
+ for img_data in item["image_data"]:
893
+ z.writestr(img_data["filename"], img_data["bytes"])
894
+ buf.seek(0)
895
+ return buf.getvalue()
896
+
897
+
898
+ def extract_and_verify_plots(
899
+ pdf_bytes: bytes,
900
+ check_missed_plots: bool = True,
901
+ verify_crops: bool = True,
902
+ verify_engines: Optional[List[str]] = None,
903
+ verify_models: Optional[Dict[str, str]] = None,
904
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
905
+ """
906
+ Runs detection + missed-plot recovery (extract_plot_images), then — if
907
+ verify_crops is True — attaches a "verification" dict to every returned
908
+ image entry, built by running the crop-quality check across every
909
+ engine in verify_engines (subset of "gemini"/"gpt"/"claude", default
910
+ ["gemini"] only). Nothing is filtered, discarded, or stored anywhere;
911
+ every crop that was detected comes back to you, so you can decide what
912
+ to do with each one — the Streamlit UI filters on majority_action, but
913
+ that's a display choice, not something baked in here.
914
+
915
+ Each image_data entry gains a "verification" key:
916
+ {"by_engine": {"gemini": {...}, "gpt": {...}, "claude": {...}},
917
+ "majority_action": "keep"|"recrop"|"discard"|"unverified",
918
+ "verified": bool}
919
+ verification is None if verify_crops=False.
920
+ """
921
+ verify_engines = verify_engines or ["gemini"]
922
+ verify_models = verify_models or {"gemini": GEMINI_MODEL, "gpt": "gpt-4o", "claude": "claude-sonnet-4-6"}
923
 
924
+ plot_results, coverage_report = extract_plot_images(pdf_bytes, check_missed_plots=check_missed_plots)
 
 
 
 
925
 
926
+ if verify_crops:
927
+ for group in plot_results:
928
+ for img in group["image_data"]:
929
+ img["verification"] = verify_crop_multi(img["bytes"], verify_engines, verify_models)
930
+ else:
931
+ for group in plot_results:
932
+ for img in group["image_data"]:
933
+ img["verification"] = None
 
 
 
 
 
 
 
 
 
 
934
 
935
+ return plot_results, coverage_report
 
936
 
 
 
 
 
937
 
938
+ # ─────────────────────────────────────────────────────────────────────────────
939
+ # STREAMLIT UI
940
+ # ─────────────────────────────────────────────────────────────────────────────
941
 
942
+ def _resize_for_display(img_bgr: np.ndarray, target_width: int = 260) -> np.ndarray:
943
+ """Resize a crop to a consistent display width so the grid looks uniform
944
+ regardless of how large or small the original detected region was —
945
+ detected plots vary a lot in native size, and letting Streamlit render
946
+ each at its raw resolution makes the grid ragged and some thumbnails
947
+ huge. Keeps aspect ratio, only ever shrinks (never upscales blurry)."""
948
+ h, w = img_bgr.shape[:2]
949
+ if w <= target_width:
950
+ return img_bgr
951
+ scale = target_width / w
952
+ new_h = max(1, int(h * scale))
953
+ return cv2.resize(img_bgr, (target_width, new_h), interpolation=cv2.INTER_AREA)
954
 
 
 
 
 
 
 
 
955
 
956
+ def _run_streamlit() -> None:
957
+ import streamlit as st
958
+ global GEMINI_MODEL
959
 
960
+ st.set_page_config(page_title="Plot Extraction + Verification", page_icon="", layout="wide")
961
+ st.title(" Plot Extraction — Detection + Missed-Plot Check + Crop Verification")
962
+ st.caption("No database — this just extracts, checks recall, and shows you the plots.")
963
 
964
+ with st.sidebar:
965
+ st.header("⚙️ Settings")
966
+
967
+ st.subheader("Gemini")
968
+ st.markdown(f"**GEMINI_API_KEY set:** {'' if GEMINI_API_KEY else ''}")
969
+ gemini_models = fetch_gemini_models_v3_plus(GEMINI_API_KEY)
970
+ selected_gemini_model = st.selectbox(
971
+ "Gemini model (3.0+)", gemini_models,
972
+ index=gemini_models.index(GEMINI_MODEL) if GEMINI_MODEL in gemini_models else 0,
973
+ disabled=not GEMINI_API_KEY,
974
+ )
975
 
976
+ st.divider()
977
+ st.subheader("Compare with other LLMs")
978
+ st.caption("Runs the same crop-quality check with each engine you enable, "
979
+ "so you can compare verdicts side by side.")
980
+ use_gpt = st.checkbox(" Also verify with GPT", value=False, disabled=not OPENAI_API_KEY,
981
+ help="OPENAI_API_KEY not set" if not OPENAI_API_KEY else None)
982
+ gpt_model = st.selectbox("GPT model", GPT_FALLBACK_MODELS, disabled=not use_gpt) if use_gpt else GPT_FALLBACK_MODELS[0]
983
+ use_claude = st.checkbox(" Also verify with Claude", value=False, disabled=not ANTHROPIC_API_KEY,
984
+ help="ANTHROPIC_API_KEY not set" if not ANTHROPIC_API_KEY else None)
985
+ claude_model = st.selectbox("Claude model", CLAUDE_FALLBACK_MODELS, disabled=not use_claude) if use_claude else CLAUDE_FALLBACK_MODELS[0]
986
 
 
 
987
  st.divider()
988
+ st.markdown(f"**OpenCV available:** {'' if CV2_AVAILABLE else ' not installed'}")
989
+ if not GEMINI_API_KEY:
990
+ st.warning("No GEMINI_API_KEY found detection will still run; the "
991
+ "missed-plot check and crop verification will be skipped.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
992
  st.divider()
 
 
 
 
 
 
993
 
994
+ check_missed = st.checkbox(" Check for missed plots", value=True, disabled=not GEMINI_API_KEY)
995
+ verify_crops = st.checkbox(" Verify crop quality", value=True, disabled=not GEMINI_API_KEY)
996
+ st.divider()
997
+ show_discarded = st.checkbox("Show discarded crops too", value=False,
998
+ help="Off by default — crops the verifier(s) recommend "
999
+ "discarding (logos, headers, non-plot content) are "
1000
+ "hidden from the results below.")
1001
+ thumb_width = st.slider("Thumbnail width (px)", 150, 500, 260, step=10)
1002
+
1003
+ uploaded = st.file_uploader("Upload PDF", type=["pdf"])
1004
+ if not uploaded:
1005
+ st.info("Upload a PDF to get started.")
1006
+ return
1007
+
1008
+ pdf_bytes = uploaded.getvalue()
1009
+ stem = uploaded.name.rsplit(".", 1)[0]
1010
+
1011
+ if not CV2_AVAILABLE:
1012
+ st.error("opencv-python is not installed — plot extraction is unavailable.")
1013
+ return
1014
+
1015
+ if st.button(" Extract Plots", type="primary", use_container_width=True):
1016
+ GEMINI_MODEL = selected_gemini_model
1017
+ verify_engines = ["gemini"] + (["gpt"] if use_gpt else []) + (["claude"] if use_claude else [])
1018
+ verify_models = {"gemini": selected_gemini_model, "gpt": gpt_model, "claude": claude_model}
1019
+
1020
+ with st.spinner(f"Detecting figures, checking for missed plots, verifying crops "
1021
+ f"({', '.join(verify_engines)})…"):
1022
+ plot_results, coverage_report = extract_and_verify_plots(
1023
+ pdf_bytes, check_missed_plots=check_missed, verify_crops=verify_crops,
1024
+ verify_engines=verify_engines, verify_models=verify_models,
1025
+ )
1026
+ st.session_state["plot_results"] = plot_results
1027
+ st.session_state["coverage_report"] = coverage_report
1028
+ st.session_state["stem"] = stem
1029
+ st.session_state["verify_engines"] = verify_engines
1030
+ st.session_state["extracted"] = True
1031
+
1032
+ if not st.session_state.get("extracted", False):
1033
+ return
1034
+
1035
+ plot_results = st.session_state["plot_results"]
1036
+ coverage_report = st.session_state["coverage_report"]
1037
+ stem = st.session_state["stem"]
1038
+ verify_engines = st.session_state.get("verify_engines", ["gemini"])
1039
+
1040
+ def _action_of(img: Dict[str, Any]) -> str:
1041
+ v = img.get("verification")
1042
+ if not v:
1043
+ return "unverified"
1044
+ return v.get("majority_action", "unverified")
1045
+
1046
+ total_images = sum(len(g["image_data"]) for g in plot_results)
1047
+ discarded_count = sum(
1048
+ 1 for g in plot_results for img in g["image_data"] if _action_of(img) == "discard"
1049
+ )
1050
 
1051
+ c1, c2, c3, c4, c5 = st.columns(5)
1052
+ c1.metric("Figure groups", len(plot_results))
1053
+ c2.metric("Total images", total_images)
1054
+ c3.metric("Detected (local)", coverage_report.get("total_detected", 0))
1055
+ c4.metric("Recovered (Gemini)", coverage_report.get("total_recovered", 0))
1056
+ c5.metric("Discarded (hidden)", discarded_count if not show_discarded else 0)
1057
+
1058
+ with st.expander(" Coverage report (missed-plot check)", expanded=False):
1059
+ if not coverage_report.get("gemini_check_ran"):
1060
+ st.warning("Skipped — no GEMINI_API_KEY or check disabled, so only local "
1061
+ "detection ran; no recall check was possible.")
1062
+ else:
1063
+ for p in coverage_report["pages"]:
1064
+ flag = " recovered figure(s) local detection missed" if p["recovered"] else ""
1065
+ st.write(f"Page {p['page']}: detected={p['detected']} "
1066
+ f"gemini_reported={p['gemini_reported']} recovered={p['recovered']}{flag}")
1067
+
1068
+ st.divider()
1069
+
1070
+ if not plot_results:
1071
+ st.warning("No figures detected in this PDF.")
1072
+ else:
1073
+ verify_models_current = {"gemini": GEMINI_MODEL, "gpt": GPT_FALLBACK_MODELS[0], "claude": CLAUDE_FALLBACK_MODELS[0]}
1074
+ any_shown = False
1075
+ for group_idx, group in enumerate(plot_results):
1076
+ visible_indices = [
1077
+ i for i, img in enumerate(group["image_data"])
1078
+ if show_discarded or _action_of(img) != "discard"
1079
+ ]
1080
+ if not visible_indices:
1081
+ continue
1082
+ any_shown = True
1083
  with st.container(border=True):
1084
+ st.markdown(f"**Page {group['page']}** — {group['caption']}")
1085
+ n_cols = min(len(visible_indices), 4) or 1
1086
+ cols = st.columns(n_cols)
1087
+ for pos, img_idx in enumerate(visible_indices):
1088
+ img = group["image_data"][img_idx]
1089
+ with cols[pos % n_cols]:
1090
+ display_img = _resize_for_display(img["array"], thumb_width)
1091
+ st.image(display_img, channels="BGR", width=thumb_width)
1092
+ label = img.get("subplot_label") or ""
1093
+ cap = img.get("subplot_caption") or ""
1094
+ if label or cap:
1095
+ st.caption(f"{label} {cap}".strip())
1096
+
1097
+ v = img.get("verification")
1098
+ any_engine_flagged_recrop = False
1099
+ if v and v.get("verified"):
1100
+ majority = v.get("majority_action", "unverified")
1101
+ badge = {"keep": "✅", "recrop": "⚠️", "discard": "❌"}.get(majority, "❔")
1102
+ label_txt = f"{badge} {majority}"
1103
+ if len(verify_engines) > 1:
1104
+ label_txt += f" ({len(v['by_engine'])} engines)"
1105
+ st.caption(label_txt)
1106
+ any_engine_flagged_recrop = any(
1107
+ verdict.get("recommended_action") == "recrop"
1108
+ for verdict in v["by_engine"].values()
1109
+ )
1110
+ if len(verify_engines) > 1:
1111
+ for engine, verdict in v["by_engine"].items():
1112
+ ebadge = {"keep": "✅", "recrop": "⚠️", "discard": "❌"}.get(
1113
+ verdict.get("recommended_action"), "❔")
1114
+ st.caption(f" {ebadge} {engine}: {verdict.get('recommended_action')} "
1115
+ f"({verdict.get('confidence', '?')})")
1116
+ if any_engine_flagged_recrop and majority != "recrop":
1117
+ st.caption(" at least one engine flagged this for recrop "
1118
+ "even though majority said otherwise")
1119
+ else:
1120
+ only = next(iter(v["by_engine"].values()), {})
1121
+ if only.get("issues"):
1122
+ st.caption(f"Issues: {', '.join(only['issues'])}")
1123
+ else:
1124
+ st.caption("❔ unverified")
1125
+ st.caption(f"source: {img.get('source', '')}")
1126
+
1127
+ # Re-split: available on any crop, but especially
1128
+ # relevant when the crop looks like it might contain
1129
+ # multiple merged charts — Gemini looks at THIS
1130
+ # specific image and decides, rather than guessing
1131
+ # from pixel density.
1132
+ if GEMINI_API_KEY:
1133
+ btn_label = " Re-split (looks merged)" if any_engine_flagged_recrop else " Check for merged charts"
1134
+ if st.button(btn_label, key=f"resplit_{group_idx}_{img_idx}",
1135
+ use_container_width=True):
1136
+ with st.spinner("Asking Gemini whether this is multiple merged charts…"):
1137
+ sub_crops = resplit_merged_crop(img["bytes"])
1138
+ if not sub_crops:
1139
+ st.info("Gemini says this is a single chart — nothing to split.")
1140
+ else:
1141
+ new_entries = []
1142
+ for k, sub_bytes in enumerate(sub_crops):
1143
+ nparr = np.frombuffer(sub_bytes, np.uint8)
1144
+ sub_arr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
1145
+ new_img = {
1146
+ "filename": img["filename"].replace(".png", f"_split{k}.png"),
1147
+ "bytes": sub_bytes,
1148
+ "array": sub_arr,
1149
+ "subplot_label": None,
1150
+ "subplot_caption": None,
1151
+ "source": "gemini_resplit",
1152
+ }
1153
+ if verify_crops:
1154
+ new_img["verification"] = verify_crop_multi(
1155
+ sub_bytes, verify_engines, verify_models_current
1156
+ )
1157
+ else:
1158
+ new_img["verification"] = None
1159
+ new_entries.append(new_img)
1160
+ st.session_state["plot_results"][group_idx]["image_data"][img_idx:img_idx + 1] = new_entries
1161
+ st.success(f"Split into {len(new_entries)} chart(s).")
1162
+ st.rerun()
1163
+
1164
+ if not any_shown:
1165
+ st.info("All detected crops were flagged for discard — enable "
1166
+ "**Show discarded crops too** in the sidebar to review them.")
1167
+
1168
+ st.divider()
1169
+ zip_bytes = create_plot_zip(plot_results)
1170
+ st.download_button(
1171
+ " Download all plots (ZIP + metadata JSON, includes discarded)",
1172
+ data=zip_bytes,
1173
+ file_name=f"{stem}_plots.zip",
1174
+ mime="application/zip",
1175
+ use_container_width=True,
1176
+ )
1177
 
1178
 
1179
  if __name__ == "__main__":
1180
+ _in_streamlit = False
1181
+ try:
1182
+ import streamlit.runtime.scriptrunner as _sr
1183
+ if _sr.get_script_run_ctx() is not None:
1184
+ _in_streamlit = True
1185
+ except Exception:
1186
+ pass
1187
+
1188
+ if _in_streamlit:
1189
+ _run_streamlit()
1190
+ else:
1191
+ print("This script now runs as a Streamlit app.\nUsage:\n streamlit run plot_extraction_verified.py")
1192
+ print(f"\nGemini model (3.0+): {GEMINI_MODEL} (GEMINI_API_KEY set: {bool(GEMINI_API_KEY)})")