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 |
-
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
API_KEY = os.getenv("GEMINI_API_KEY", " ")
|
| 25 |
|
| 26 |
-
_cached_model: Optional[str] = None
|
| 27 |
-
|
| 28 |
|
| 29 |
-
def
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 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 |
-
|
| 41 |
-
return _cached_model
|
| 42 |
models = resp.json().get("models", [])
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
"gemini-2.5-flash",
|
| 47 |
-
"gemini-2.5-flash-preview",
|
| 48 |
-
"gemini-2.0-flash",
|
| 49 |
-
"gemini-2.0-flash-lite",
|
| 50 |
]
|
| 51 |
-
|
| 52 |
-
for
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 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"
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
-
def
|
| 72 |
"""
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
"""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 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 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# -
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
-
def
|
| 170 |
"""
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
"""
|
| 174 |
-
|
| 175 |
-
if
|
| 176 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
| 192 |
|
| 193 |
-
if not candidate_tops:
|
| 194 |
-
return crop_rect
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
-
|
| 203 |
-
# Plot-likeness filter (reject text/diagrams)
|
| 204 |
-
# ----------------------------
|
| 205 |
-
def plot_likeness_score(bgr: np.ndarray) -> float:
|
| 206 |
"""
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
| 209 |
"""
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 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 |
-
|
| 266 |
-
rect = page.rect
|
| 267 |
-
box = item.get("box_2d", None)
|
| 268 |
-
if not box or len(box) != 4:
|
| 269 |
-
continue
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 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 |
-
|
| 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 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 319 |
-
|
|
|
|
| 320 |
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
|
| 330 |
-
st.
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
-
with st.sidebar:
|
| 335 |
-
api_key = st.text_input("Gemini API Key", type="password")
|
| 336 |
st.divider()
|
| 337 |
-
st.
|
| 338 |
-
|
| 339 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
with st.container(border=True):
|
| 368 |
-
st.markdown(f"**{
|
| 369 |
-
|
| 370 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
|
| 373 |
if __name__ == "__main__":
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)})")
|