AbhijitClemson commited on
Commit
e5cd0f8
·
verified ·
1 Parent(s): c48f590

Upload Pdf_ImageExtraction.py

Browse files
page_files/categorized/Backend/Pdf_ImageExtraction.py CHANGED
@@ -1,390 +1,379 @@
1
- import os
2
- import re
 
 
 
 
 
 
 
3
  import json
4
- import math
5
  import tempfile
6
- import fitz # PyMuPDF
7
- import cv2
 
8
  import numpy as np
9
- from PIL import Image
10
- import streamlit as st
11
 
12
- # -------------------
13
- # Config
14
- # -------------------
15
- DPI = 300
16
- OUT_DIR = "outputs"
17
 
18
- KEEP_ONLY_STRESS_STRAIN = False
19
 
20
- CAP_RE = re.compile(r"^(Fig\.?\s*\d+|Figure\s*\d+)\b", re.IGNORECASE)
21
- SS_KW = re.compile(
22
- r"(stress\s*[-–]?\s*strain|stress|strain|tensile|MPa|GPa|kN|yield|elongation)",
23
- re.IGNORECASE
24
- )
25
 
26
- # -------------------
27
- # Render helpers
28
- # -------------------
29
- def render_page(page, dpi=DPI):
30
- mat = fitz.Matrix(dpi/72, dpi/72)
31
- pix = page.get_pixmap(matrix=mat, alpha=False)
32
- img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
33
- return img, mat
34
-
35
- def pdf_to_px_bbox(bbox_pdf, mat):
36
- x0, y0, x1, y1 = bbox_pdf
37
- sx, sy = mat.a, mat.d
38
- return (int(float(x0) * sx), int(float(y0) * sy), int(float(x1) * sx), int(float(y1) * sy))
39
-
40
- def safe_crop_px(pil_img, box):
41
- if not isinstance(box, (tuple, list)):
42
- return None
43
- if len(box) == 1 and isinstance(box[0], (tuple, list)) and len(box[0]) == 4:
44
- box = box[0]
45
- if len(box) != 4:
46
- return None
47
-
48
- x0, y0, x1, y1 = box
49
- if any(isinstance(v, (tuple, list)) for v in (x0, y0, x1, y1)):
50
- return None
51
 
 
 
 
 
 
52
  try:
53
- x0 = int(x0)
54
- y0 = int(y0)
55
- x1 = int(x1)
56
- y1 = int(y1)
57
- except (TypeError, ValueError):
58
- return None
59
-
60
- if x1 < x0:
61
- x0, x1 = x1, x0
62
- if y1 < y0:
63
- y0, y1 = y1, y0
64
-
65
- W, H = pil_img.size
66
- x0 = max(0, min(W, x0))
67
- x1 = max(0, min(W, x1))
68
- y0 = max(0, min(H, y0))
69
- y1 = max(0, min(H, y1))
70
- if x1 <= x0 or y1 <= y0:
71
- return None
72
- return pil_img.crop((x0, y0, x1, y1))
73
-
74
- # -------------------
75
- # Captions
76
- # -------------------
77
- def find_caption_blocks(page):
78
- caps = []
79
- blocks = page.get_text("blocks")
80
- for b in blocks:
81
- x0, y0, x1, y1, text = b[0], b[1], b[2], b[3], b[4]
82
- t = " ".join(str(text).strip().split())
83
- if CAP_RE.match(t):
84
- caps.append({"bbox": (x0, y0, x1, y1), "text": t})
85
- return caps
86
-
87
- # -------------------
88
- # Dedupe: dHash
89
- # -------------------
90
- def dhash64(pil_img):
91
- gray = pil_img.convert("L").resize((9, 8), Image.LANCZOS)
92
- pixels = list(gray.getdata())
93
- bits = 0
94
- for r in range(8):
95
- for c in range(8):
96
- left = pixels[r * 9 + c]
97
- right = pixels[r * 9 + c + 1]
98
- bits = (bits << 1) | (1 if left > right else 0)
99
- return bits
100
-
101
- # -------------------
102
- # Rejectors
103
- # -------------------
104
- def has_colorbar_like_strip(pil_img):
105
- img = np.array(pil_img)
106
- if img.ndim != 3:
107
- return False
108
- H, W, _ = img.shape
109
- if W < 250 or H < 150:
110
- return False
111
- strip_w = max(18, int(0.07 * W))
112
- strip = img[:, W-strip_w:W, :]
113
- q = (strip // 24).reshape(-1, 3)
114
- uniq = np.unique(q, axis=0)
115
- return len(uniq) > 70
116
-
117
- def texture_score(pil_img):
118
- gray = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2GRAY)
119
- lap = cv2.Laplacian(gray, cv2.CV_64F)
120
- return float(lap.var())
121
-
122
- def is_mostly_legend(pil_img):
123
- gray = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2GRAY)
124
- bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
125
- bw = cv2.medianBlur(bw, 3)
126
- H, W = bw.shape
127
- fill = float(np.count_nonzero(bw)) / float(H * W)
128
- return (0.03 < fill < 0.18) and (min(H, W) < 260)
129
-
130
- # -------------------
131
- # Plot detection
132
- # -------------------
133
- def detect_axes_lines(pil_img):
134
- gray = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2GRAY)
135
- edges = cv2.Canny(gray, 50, 150)
136
- H, W = gray.shape
137
- min_len = int(0.28 * min(H, W))
138
-
139
- lines = cv2.HoughLinesP(
140
- edges, 1, np.pi/180,
141
- threshold=90,
142
- minLineLength=min_len,
143
- maxLineGap=14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  )
145
- if lines is None:
146
- return None, None
147
-
148
- horizontals, verticals = [], []
149
- for x1, y1, x2, y2 in lines[:, 0]:
150
- dx, dy = abs(x2-x1), abs(y2-y1)
151
- length = math.hypot(dx, dy)
152
- if dy < 18 and dx > 0.35 * W:
153
- horizontals.append((length, (x1, y1, x2, y2)))
154
- if dx < 18 and dy > 0.35 * H:
155
- verticals.append((length, (x1, y1, x2, y2)))
156
-
157
- if not horizontals or not verticals:
158
- return None, None
159
-
160
- horizontals.sort(key=lambda t: t[0], reverse=True)
161
- verticals.sort(key=lambda t: t[0], reverse=True)
162
- return horizontals[0][1], verticals[0][1]
163
-
164
- def axis_intersection_ok(x_axis, y_axis, W, H):
165
- xa_y = int(round((x_axis[1] + x_axis[3]) / 2))
166
- ya_x = int(round((y_axis[0] + y_axis[2]) / 2))
167
- if not (0 <= xa_y < H and 0 <= ya_x < W):
168
- return False
169
- if ya_x > int(0.95 * W) or xa_y < int(0.05 * H):
170
- return False
171
- return True
172
-
173
- def tick_text_presence_score(pil_img, x_axis, y_axis):
174
- img = np.array(pil_img)
175
- gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
176
- bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
177
- bw = cv2.medianBlur(bw, 3)
178
-
179
- H, W = gray.shape
180
- xa_y = int(round((x_axis[1] + x_axis[3]) / 2))
181
- ya_x = int(round((y_axis[0] + y_axis[2]) / 2))
182
-
183
- y0a = max(0, xa_y - 40)
184
- y1a = min(H, xa_y + 110)
185
- x_roi = bw[y0a:y1a, 0:W]
186
-
187
- x0b = max(0, ya_x - 180)
188
- x1b = min(W, ya_x + 50)
189
- y_roi = bw[0:H, x0b:x1b]
190
-
191
- def count_small_components(mask):
192
- num, _, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
193
- cnt = 0
194
- for i in range(1, num):
195
- x, y, w, h, area = stats[i]
196
- if 4 <= w <= 150 and 4 <= h <= 150 and 20 <= area <= 5000:
197
- cnt += 1
198
- return cnt
199
-
200
- return count_small_components(x_roi) + count_small_components(y_roi)
201
-
202
- def is_real_plot(pil_img):
203
- if has_colorbar_like_strip(pil_img):
204
- return False
205
- if is_mostly_legend(pil_img):
206
- return False
207
-
208
- x_axis, y_axis = detect_axes_lines(pil_img)
209
- if x_axis is None or y_axis is None:
210
- return False
211
-
212
- arr = np.array(pil_img)
213
- H, W = arr.shape[0], arr.shape[1]
214
- if not axis_intersection_ok(x_axis, y_axis, W, H):
215
- return False
216
-
217
- if texture_score(pil_img) > 2200:
218
- return False
219
-
220
- score = tick_text_presence_score(pil_img, x_axis, y_axis)
221
- return score >= 18
222
-
223
- # -------------------
224
- # Candidate boxes in a region
225
- # -------------------
226
- def connected_components_boxes(pil_img):
227
- img_bgr = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
228
- gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
229
- mask = (gray < 245).astype(np.uint8) * 255
230
- mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8), iterations=2)
231
- num, _, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
232
-
233
- boxes = []
234
- for i in range(1, num):
235
- x, y, w, h, area = stats[i]
236
- boxes.append((int(area), (int(x), int(y), int(x + w), int(y + h))))
237
- boxes.sort(key=lambda t: t[0], reverse=True)
238
- return boxes
239
-
240
- def expand_box(box, W, H, left=0.10, right=0.06, top=0.06, bottom=0.18):
241
- x0, y0, x1, y1 = box
242
- bw = x1 - x0
243
- bh = y1 - y0
244
- ex0 = max(0, int(x0 - left * bw))
245
- ex1 = min(W, int(x1 + right * bw))
246
- ey0 = max(0, int(y0 - top * bh))
247
- ey1 = min(H, int(y1 + bottom * bh))
248
- return (ex0, ey0, ex1, ey1)
249
-
250
- # -------------------
251
- # Crop plot from caption
252
- # -------------------
253
- def crop_plot_from_caption(page_img, cap_bbox_pdf, mat):
254
- cap_px = pdf_to_px_bbox(cap_bbox_pdf, mat)
255
- cap_y0 = cap_px[1]
256
- cap_y1 = cap_px[3]
257
-
258
- W, H = page_img.size
259
- search_top = max(0, cap_y0 - int(0.95 * H))
260
- search_bot = min(H, cap_y1 + int(0.20 * H))
261
- region = safe_crop_px(page_img, (0, search_top, W, search_bot))
262
- if region is None:
263
- return None
264
-
265
- comps = connected_components_boxes(region)
266
- best = None
267
- best_area = -1
268
-
269
- for area, box in comps[:35]:
270
- x0, y0, x1, y1 = box
271
- bw = x1 - x0
272
- bh = y1 - y0
273
- if bw < 220 or bh < 180:
274
- continue
275
 
276
- exp = expand_box(box, region.size[0], region.size[1])
277
- cand = safe_crop_px(region, exp)
278
- if cand is None:
279
- continue
280
 
281
- if not is_real_plot(cand):
 
 
 
282
  continue
 
 
 
283
 
284
- if area > best_area:
285
- best_area = area
286
- best = cand
287
 
288
- return best
 
 
 
289
 
290
- # -------------------
291
- # Streamlit UI
292
- # -------------------
293
- def run_extraction(pdf_path, paper_id="uploaded_paper"):
294
- out_paper = os.path.join(OUT_DIR, paper_id)
295
- out_imgs = os.path.join(out_paper, "plots_with_axes")
296
- os.makedirs(out_imgs, exist_ok=True)
297
 
298
- doc = fitz.open(pdf_path)
299
- results = []
300
- seen = set()
301
- saved = 0
 
 
 
 
302
 
303
- for p in range(len(doc)):
304
- page = doc[p]
305
- caps = find_caption_blocks(page)
306
- if not caps:
 
 
 
 
307
  continue
308
 
309
- page_img, mat = render_page(page, dpi=DPI)
310
-
311
- for cap in caps:
312
- cap_text = cap["text"]
313
-
314
- if KEEP_ONLY_STRESS_STRAIN and not SS_KW.search(cap_text):
315
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
- fig = crop_plot_from_caption(page_img, cap["bbox"], mat)
318
- if fig is None:
319
- continue
 
320
 
321
- if fig.size[0] > 8 and fig.size[1] > 8:
322
- fig = fig.crop((2, 2, fig.size[0]-2, fig.size[1]-2))
 
 
 
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  try:
325
- h = dhash64(fig)
326
  except Exception:
327
- continue
328
-
329
- if h in seen:
330
- continue
331
- seen.add(h)
332
-
333
- img_name = f"p{p+1:02d}_{saved:04d}.png"
334
- img_path = os.path.join(out_imgs, img_name)
335
- fig.save(img_path)
336
 
337
- results.append({
338
- "page": p + 1,
339
- "caption": cap_text,
340
- "image": img_path
341
- })
342
- saved += 1
 
343
 
344
- out_json = os.path.join(out_paper, "plots_with_axes.json")
345
- with open(out_json, "w", encoding="utf-8") as f:
346
- json.dump(results, f, indent=2, ensure_ascii=False)
347
 
348
- return results, out_json
349
 
 
 
 
350
  def main():
351
- st.set_page_config(page_title="Research Paper Plot Extractor", layout="wide")
352
- st.title(" Plot Extractor (Upload PDF)")
353
-
354
- uploaded = st.file_uploader("Upload a research paper PDF", type=["pdf"])
355
- if not uploaded:
356
- st.info("Upload a PDF to extract plots.")
357
- return
358
-
359
- paper_id = os.path.splitext(uploaded.name)[0].replace(" ", "_")
360
-
361
- with tempfile.TemporaryDirectory() as tmpdir:
362
- pdf_path = os.path.join(tmpdir, uploaded.name)
363
- with open(pdf_path, "wb") as f:
364
- f.write(uploaded.read())
365
-
366
- with st.spinner("Extracting plots..."):
367
- results, out_json = run_extraction(pdf_path, paper_id=paper_id)
368
-
369
- st.success(f"Extracted {len(results)} plots.")
370
-
371
- # Show images + captions
372
- for r in results:
373
- st.markdown(f"**Page {r['page']}** — {r['caption']}")
374
- st.image(r["image"], use_container_width=True)
375
- st.divider()
376
-
377
- # JSON viewer + download
378
- st.subheader("JSON Output")
379
- st.json(results)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
 
381
- with open(out_json, "rb") as f:
382
- st.download_button(
383
- "Download JSON",
384
- data=f,
385
- file_name=os.path.basename(out_json),
386
- mime="application/json"
387
- )
388
 
389
  if __name__ == "__main__":
390
- main()
 
1
+ import logging
2
+
3
+ import requests
4
+
5
+ log = logging.getLogger(__name__)
6
+
7
+ import streamlit as st
8
+ import google.generativeai as 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 = get_available_model(API_KEY)
66
+ API_URL = (
67
+ f"https://generativelanguage.googleapis.com/v1beta/"
68
+ f"models/{GEMINI_MODEL}:generateContent"
69
+ f"?key={API_KEY}"
70
+ )
71
+
72
+
73
+
74
+
75
+ def robust_json_load(text: str):
76
+ """
77
+ Handles cases where Gemini returns JSON + extra text.
78
+ """
79
+ if not text:
80
+ raise ValueError("Empty model output")
81
+
82
+ cleaned = text.strip().replace("```json", "```").replace("```", "").strip()
83
+
84
+ # Find first JSON start
85
+ s1 = cleaned.find("[")
86
+ s2 = cleaned.find("{")
87
+ if s1 == -1 and s2 == -1:
88
+ raise ValueError("No JSON found in model output")
89
+
90
+ start = s1 if (s1 != -1 and (s2 == -1 or s1 < s2)) else s2
91
+ dec = json.JSONDecoder()
92
+ parsed, _ = dec.raw_decode(cleaned[start:])
93
+ return parsed
94
+
95
+
96
+ def get_plot_data_from_llm(GEMINI_MODEL, pdf_path: str):
97
+ sample_file = genai.upload_file(path=pdf_path)
98
+
99
+ prompt = prompt = """
100
+ Analyze this PDF and identify ONLY true data plots:
101
+ (Line, Scatter, Bar, Histogram, Heatmap, Box, Violin).
102
+ EXCLUDE: flowcharts, block diagrams, photos, icons, gauges/dials,
103
+ graphical abstracts, tables, and pure text regions.
104
+
105
+ DETECTION RULES:
106
+ 1) INCLUDE ALL SUBPLOTS: If a figure has panels like (a), (b), (c),
107
+ return ONE bounding box covering the ENTIRE figure group including
108
+ all panels, axes, and legends.
109
+ 2) CROP AREA: box_2d MUST include:
110
+ - The full plot area (all data points, bars, lines)
111
+ - ALL axis lines and tick marks
112
+ - ALL axis labels and tick labels
113
+ - The legend if present
114
+ - A small margin above the top of the plot
115
+ box_2d MUST EXCLUDE figure caption text below the figure.
116
+ 3) IMPORTANT: Make the bounding box GENEROUS — it is better to include
117
+ too much whitespace than to cut off any part of the plot.
118
+ 4) DO NOT return tables or paragraphs as images.
119
+ 5) Skip graphical abstracts and decorative figures.
120
+ 6) Extract the FULL caption text into "caption".
121
+
122
+ COORDINATES:
123
+ - box_2d is [ymin, xmin, ymax, xmax] in 0..1000 normalized page coordinates.
124
+ - ymin should be ABOVE the top of the plot area.
125
+ - ymax should be BELOW the bottom axis but ABOVE the caption text.
126
+ - xmin should be LEFT of the y-axis labels.
127
+ - xmax should be RIGHT of the rightmost data point or legend.
128
+
129
+ Return ONLY a raw JSON array like:
130
+ [
131
+ {
132
+ "caption":"...",
133
+ "page": 1,
134
+ "box_2d":[ymin,xmin,ymax,xmax],
135
+ "figure_kind":"plot|nonplot",
136
+ "plot_type":"scatter|line|bar|hist|heatmap|box|violin|other"
137
+ }
138
+ ]
139
+ IMPORTANT: Only include items where figure_kind="plot".
140
+ """
141
+
142
+ # Try to force JSON + reduce randomness
143
+ generation_config = {
144
+ "temperature": 0.0,
145
+ "top_p": 1.0,
146
+ "top_k": 1,
147
+ "max_output_tokens": 4096,
148
+ }
149
+
150
+ # Some environments accept response_mime_type; if yours errors, remove it.
151
+ response = GEMINI_MODEL.generate_content(
152
+ [sample_file, prompt],
153
+ generation_config=generation_config
154
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
+ data = robust_json_load(response.text)
 
 
 
157
 
158
+ # Hard filter if model still returns nonplots
159
+ filtered = []
160
+ for it in data if isinstance(data, list) else []:
161
+ if not isinstance(it, dict):
162
  continue
163
+ if (it.get("figure_kind") or "").strip().lower() == "plot":
164
+ filtered.append(it)
165
+ return filtered
166
 
 
 
 
167
 
168
+ # ----------------------------
169
+ # Caption trimming using PDF text blocks
170
+ # ----------------------------
171
+ CAP_RE = re.compile(r"^(fig\.?\s*\d+|figure\s*\d+)\b", re.IGNORECASE)
172
 
 
 
 
 
 
 
 
173
 
174
+ def trim_caption_region(page: fitz.Page, crop_rect: fitz.Rect) -> fitz.Rect:
175
+ """
176
+ If crop includes caption text (Fig./Figure...) near the bottom,
177
+ shrink ymax to exclude it.
178
+ """
179
+ blocks = page.get_text("blocks") # (x0,y0,x1,y1,text,block_no,block_type)
180
+ if not blocks:
181
+ return crop_rect
182
 
183
+ # Look for caption blocks that intersect crop and are below plot area
184
+ candidate_tops = []
185
+ for b in blocks:
186
+ x0, y0, x1, y1, text = b[0], b[1], b[2], b[3], b[4]
187
+ if not text:
188
+ continue
189
+ t = text.strip().replace("\n", " ")
190
+ if not CAP_RE.search(t):
191
  continue
192
 
193
+ block_rect = fitz.Rect(x0, y0, x1, y1)
194
+ if block_rect.intersects(crop_rect):
195
+ # likely caption inside crop, use its top boundary
196
+ candidate_tops.append(y0)
197
+
198
+ if not candidate_tops:
199
+ return crop_rect
200
+
201
+ cap_top = min(candidate_tops)
202
+ # Reduce crop bottom just above caption top
203
+ new = fitz.Rect(crop_rect.x0, crop_rect.y0, crop_rect.x1, min(crop_rect.y1, cap_top - 2))
204
+ return new if new.y1 > new.y0 + 10 else crop_rect
205
+
206
+
207
+ # ----------------------------
208
+ # Plot-likeness filter (reject text/diagrams)
209
+ # ----------------------------
210
+ def plot_likeness_score(bgr: np.ndarray) -> float:
211
+ """
212
+ Heuristic: plots usually have strong horizontal/vertical lines (axes, grids),
213
+ and moderate edge density. Text blocks often have many tiny components and no axes.
214
+ """
215
+ gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
216
+ h, w = gray.shape[:2]
217
+ if h < 40 or w < 40:
218
+ return 0.0
219
+
220
+ # edges
221
+ edges = cv2.Canny(gray, 60, 160)
222
+ edge_density = float(np.mean(edges > 0))
223
+
224
+ # detect long straight lines (axes/grid)
225
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80,
226
+ minLineLength=int(min(h, w) * 0.35), maxLineGap=10)
227
+
228
+
229
+ hv_lines = 0
230
+ if lines is not None:
231
+ for x1, y1, x2, y2 in lines[:, 0]:
232
+ dx = abs(x2 - x1)
233
+ dy = abs(y2 - y1)
234
+ if dx > 3 * dy or dy > 3 * dx:
235
+ hv_lines += 1
236
+
237
+ # connected components for "textiness"
238
+ _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
239
+ num_labels, _, stats, _ = cv2.connectedComponentsWithStats(bw, connectivity=8)
240
+ # count small-ish components (typical for dense text)
241
+ small = 0
242
+ for i in range(1, num_labels):
243
+ area = stats[i, cv2.CC_STAT_AREA]
244
+ if 10 <= area <= 120:
245
+ small += 1
246
+ small_density = small / (h * w / 10000.0 + 1e-6)
247
+
248
+ # Score: prefer hv_lines + moderate edges; penalize extreme textiness
249
+ 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))
250
+ return float(max(0.0, min(1.0, score)))
251
+
252
+
253
+ def looks_like_plot(bgr: np.ndarray, threshold: float = 0.35) -> Tuple[bool, float]:
254
+ s = plot_likeness_score(bgr)
255
+ return (s >= threshold), s
256
+
257
+
258
+ # ----------------------------
259
+ # Extraction with better padding
260
+ # ----------------------------
261
+ def extract_plots(pdf_path: str, plot_data: list, pad: int, score_thresh: float):
262
+ doc = fitz.open(pdf_path)
263
+ results = []
264
 
265
+ for idx, item in enumerate(plot_data):
266
+ page_num = int(item.get("page", 1)) - 1
267
+ if page_num < 0 or page_num >= len(doc):
268
+ continue
269
 
270
+ page = doc[page_num]
271
+ rect = page.rect
272
+ box = item.get("box_2d", None)
273
+ if not box or len(box) != 4:
274
+ continue
275
 
276
+ # normalized 0..1000 → absolute page coords
277
+ ymin = (box[0] * rect.height / 1000.0)
278
+ xmin = (box[1] * rect.width / 1000.0)
279
+ ymax = (box[2] * rect.height / 1000.0)
280
+ xmax = (box[3] * rect.width / 1000.0)
281
+
282
+ # Asymmetric padding: keep labels, avoid caption blow-up
283
+ pad_left = pad * 1.4
284
+ pad_right = pad * 0.9
285
+ pad_top = pad * 0.9
286
+ pad_bottom = pad * 0.7 # smaller bottom pad to avoid captions
287
+
288
+ fitz_rect = fitz.Rect(
289
+ max(0, xmin - pad_left),
290
+ max(0, ymin - pad_top),
291
+ min(rect.width, xmax + pad_right),
292
+ min(rect.height, ymax + pad_bottom)
293
+ )
294
+
295
+ # Trim caption if accidentally included
296
+ fitz_rect = trim_caption_region(page, fitz_rect)
297
+
298
+ # Render
299
+ pix = page.get_pixmap(clip=fitz_rect, dpi=300)
300
+ img_path = f"plot_res_{idx}.png"
301
+ pix.save(img_path)
302
+
303
+ # Post-filter: reject non-plots
304
+ bgr = cv2.imread(img_path)
305
+ ok, score = looks_like_plot(bgr, threshold=score_thresh)
306
+
307
+ if not ok:
308
+ # remove saved non-plot image
309
  try:
310
+ os.remove(img_path)
311
  except Exception:
312
+ pass
313
+ continue
 
 
 
 
 
 
 
314
 
315
+ results.append({
316
+ "caption": item.get("caption", f"Figure {idx}"),
317
+ "page": item.get("page", 1),
318
+ "path": img_path,
319
+ "plot_score": round(score, 3),
320
+ "plot_type": item.get("plot_type", "unknown")
321
+ })
322
 
323
+ doc.close()
324
+ return results
 
325
 
 
326
 
327
+ # ----------------------------
328
+ # Streamlit UI
329
+ # ----------------------------
330
  def main():
331
+ st.set_page_config(layout="wide", page_title="Scientific Figure Extractor")
332
+ st.title("Scientific Figure & Subplot Extractor")
333
+
334
+ if "raw_data" not in st.session_state:
335
+ st.session_state.raw_data = None
336
+ if "temp_pdf" not in st.session_state:
337
+ st.session_state.temp_pdf = None
338
+
339
+ with st.sidebar:
340
+ api_key = st.text_input("Gemini API Key", type="password")
341
+ st.divider()
342
+ st.subheader("Crop & Filter")
343
+ crop_pad = st.slider("Padding (px-like on page coords)", 0, 80, 22)
344
+ score_thresh = st.slider("Reject non-plots (higher = stricter)", 0.10, 0.80, 0.20, 0.01)
345
+
346
+ uploaded_file = st.file_uploader("Upload PDF", type="pdf")
347
+
348
+ if uploaded_file and api_key:
349
+ if st.button("Extract All Data Figures"):
350
+ with st.spinner("AI is scanning for plots and sub-panels..."):
351
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
352
+ tmp.write(uploaded_file.getbuffer())
353
+ st.session_state.temp_pdf = tmp.name
354
+
355
+ # model = init_gemini(api_key)
356
+ st.session_state.raw_data = get_plot_data_from_llm(GEMINI_MODEL, st.session_state.temp_pdf)
357
+ st.success(f"Detected {len(st.session_state.raw_data)} candidate plot figures (LLM).")
358
+
359
+ # Results Display
360
+ if st.session_state.raw_data and st.session_state.temp_pdf:
361
+ st.divider()
362
+ processed_figures = extract_plots(
363
+ st.session_state.temp_pdf,
364
+ st.session_state.raw_data,
365
+ pad=crop_pad,
366
+ score_thresh=score_thresh
367
+ )
368
+
369
+ st.success(f"Kept {len(processed_figures)} figures after non-plot filtering.")
370
+
371
+ for fig in processed_figures:
372
+ with st.container(border=True):
373
+ st.markdown(f"**{fig['caption']}**")
374
+ st.caption(f"Page {fig['page']} | plot_score={fig['plot_score']} | plot_type={fig['plot_type']}")
375
+ st.image(fig["path"], use_container_width=True)
376
 
 
 
 
 
 
 
 
377
 
378
  if __name__ == "__main__":
379
+ main()