Upload Pdf_ImageExtraction.py
Browse files
page_files/categorized/Backend/Pdf_ImageExtraction.py
CHANGED
|
@@ -1,390 +1,379 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import json
|
| 4 |
-
import math
|
| 5 |
import tempfile
|
| 6 |
-
import
|
| 7 |
-
import
|
|
|
|
| 8 |
import numpy as np
|
| 9 |
-
|
| 10 |
-
|
| 11 |
|
| 12 |
-
# -------------------
|
| 13 |
-
# Config
|
| 14 |
-
# -------------------
|
| 15 |
-
DPI = 300
|
| 16 |
-
OUT_DIR = "outputs"
|
| 17 |
|
| 18 |
-
KEEP_ONLY_STRESS_STRAIN = False
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
)
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 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 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
#
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 277 |
-
cand = safe_crop_px(region, exp)
|
| 278 |
-
if cand is None:
|
| 279 |
-
continue
|
| 280 |
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
| 282 |
continue
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
-
if area > best_area:
|
| 285 |
-
best_area = area
|
| 286 |
-
best = cand
|
| 287 |
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
-
for
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
continue
|
| 308 |
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
| 323 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
try:
|
| 325 |
-
|
| 326 |
except Exception:
|
| 327 |
-
|
| 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 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
| 343 |
|
| 344 |
-
|
| 345 |
-
|
| 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(
|
| 352 |
-
st.title("
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
st.
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|