"""PDF 图片与表格提取。 DocLayout-YOLO 检测 figure/table 内容区域 → PDF 文本流定位 caption → 只渲染配到 Figure/Table 标题的,用 caption 自带权威 ID 命名。没配到标题的(Algorithm 伪代码、 无编号附录表、DocLayout 误检碎片)一律过滤,不输出。 caption 定位用 PDF 文本而非 DocLayout 的 caption box —— 后者检测不稳(多行标题只 框一行→截断、漏检→无标题、配对错误→串台)。page.get_text("dict") 找以 "Figure N"/"Table N" 开头的文本块:文本块天然含完整多行标题,且其 ID 即论文实际 编号,直接命名规避串台。figure 标题优先在下方、table 标题优先在上方配对。 """ from __future__ import annotations import json import logging import re from dataclasses import dataclass from pathlib import Path import pymupdf from app.services.layout_detector import LayoutBox, detect_page_layout from app.services.pdf_downloader import paper_dir from app.utils import PAPERS_DIR, TMP_DIR logger = logging.getLogger(__name__) # 截图区域的外边距(单位: pt) _REGION_PADDING = 5 # 渲染倍率(3x 保证清晰度) _RENDER_ZOOM = 3 # 相邻 box 聚类间距(单位: pt)— 同一 figure/table 的碎片间距通常 < 15pt _CLUSTER_GAP = 15 # 最小 bbox 面积(单位: pt²)— 过滤 icon/logo 等微小误检 _MIN_BOX_AREA = 2000 # caption 文本块与 figure/table 内容块的最大垂直距离(单位: pt) _CAPTION_MATCH_DISTANCE = 120 # 游离碎片(配不到 caption)并入紧邻已配 cluster 的最大垂直间距(单位: pt) # 容下多面板图的 "(a)/(b)" 子图标占位(实测 Figure 4 两面板间距 36pt) _ABSORB_GAP = 60 # caption 开头标记:Figure 3 / Fig. 3 / Table C1 / Figure 3.5 等(大小写均可) # 编号 = 数字开头 或 字母+数字(附录 C1);行首匹配,规避正文 "see Table 3" 引用。 # 否定前瞻再排除多图引用型正文 —— "Figure 14 and 15 show..." / "Figure 1 to 3" / # "Table 2, 3" 这类引用多张图表的句子不是独立标题,真标题从不引用多个编号。 _CAPTION_HEAD_RE = re.compile( r"^\s*(Figure|Fig\.?|Table)\b\.?\s+([0-9][0-9A-Za-z.]*|[A-Z]\d[0-9A-Za-z.]*)" r"(?![0-9A-Za-z.])" # 编号须是完整 token,防止 "Figure 14" 回溯成 "Figure 1" 逃逸 r"(?!\s*(?:and|to|through|vs\.?|&)\s+\d)" # "Figure 3 and 4" / "Figure 1 to 3" r"(?!\s*,\s*\d)", # "Figure 3, 4" re.IGNORECASE, ) # ── Box 聚类 ───────────────────────────────────────────────────────── class _BoxCluster: """合并后的布局区域(由一个或多个相邻 LayoutBox 组成)。""" __slots__ = ("x0", "y0", "x1", "y1", "boxclass") def __init__(self, boxes: list): self.x0 = min(b.x0 for b in boxes) self.y0 = min(b.y0 for b in boxes) self.x1 = max(b.x1 for b in boxes) self.y1 = max(b.y1 for b in boxes) self.boxclass = boxes[0].boxclass @dataclass(frozen=True) class _CaptionBlock: """从 PDF 文本流提取的标题块:自带权威 ID、完整多行文本、精确 bbox。""" id: str # "Figure 3" / "Table C1" kind: str # "figure" | "table" text: str # 完整多行标题文本 bbox: list[float] # [x0, y0, x1, y1] def _cluster_to_box(cluster: _BoxCluster) -> list[float]: return [ round(float(cluster.x0), 1), round(float(cluster.y0), 1), round(float(cluster.x1), 1), round(float(cluster.y1), 1), ] def _cluster_boxes(boxes: list, gap: float = _CLUSTER_GAP) -> list[_BoxCluster]: """将相邻的同类型 box 合并为聚类。""" if not boxes: return [] n = len(boxes) parent = list(range(n)) def find(x: int) -> int: while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a: int, b: int) -> None: ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb for i in range(n): bi = boxes[i] for j in range(i + 1, n): bj = boxes[j] if bi.boxclass != bj.boxclass: continue h_gap = max(0.0, max(bi.x0, bj.x0) - min(bi.x1, bj.x1)) v_gap = max(0.0, max(bi.y0, bj.y0) - min(bi.y1, bj.y1)) h_overlap = bi.x1 > bj.x0 - gap and bj.x1 > bi.x0 - gap v_overlap = bi.y1 > bj.y0 - gap and bj.y1 > bi.y0 - gap if (h_gap <= gap and v_overlap) or (v_gap <= gap and h_overlap): union(i, j) groups: dict[int, list] = {} for i in range(n): groups.setdefault(find(i), []).append(boxes[i]) return [_BoxCluster(members) for members in groups.values()] def _find_caption_blocks(page) -> list[_CaptionBlock]: """从页面文本流提取以 "Figure N"/"Table N"/"Fig. N" 开头的标题块。 用 PDF 文本而非 DocLayout caption box:文本块天然含完整多行标题, 且其 ID 即论文实际编号(如 "Table C1"),权威且不依赖模型检测。 """ try: d = page.get_text("dict") except Exception: return [] results: list[_CaptionBlock] = [] for block in d.get("blocks", []): if block.get("type") != 0: # 仅文本块 continue lines = block.get("lines", []) if not lines: continue line_texts = [ "".join(span.get("text", "") for span in line.get("spans", [])) for line in lines ] joined = " ".join(t.strip() for t in line_texts if t.strip()) m = _CAPTION_HEAD_RE.match(joined) if not m: continue kind_word, num = m.group(1), m.group(2) is_table = kind_word.lower().startswith("table") bbox = block.get("bbox") if not bbox or len(bbox) != 4: continue full_text = joined results.append( _CaptionBlock( id=f"{'Table' if is_table else 'Figure'} {num}", kind="table" if is_table else "figure", text=full_text, bbox=[float(v) for v in bbox], ) ) return results def _pair_caption_blocks( content_clusters: list[_BoxCluster], caption_blocks: list[_CaptionBlock], ) -> dict[int, list[int]]: """每个 caption 配对其垂直 span 内的所有同类型 cluster(支持复合图/子表)。 不预设标题在内容上方还是下方 —— figure 惯例标题在下、table 惯例标题在上, 但不少论文反向排版(table 标题在表下方);用方向作硬约束或加错向惩罚会把 "同页相邻两张表" 错并(标题居中的那张吞掉邻居)。改为上下两侧平等地按垂直 距离打分,每个 cluster 唯一归属最近的同类 caption,但一个 caption 可被多个 cluster 共享 —— 这样一张被 DocLayout 切成多个稀疏子框的复合图/复合表,能 整体配到它的主标题(而非只截其中一个子图/子表)。 Returns: caption_idx → [cluster_idx, ...],按 cluster 在页面上的位置排序, 保证合并/渲染顺序稳定。 """ candidates: list[tuple[float, int, int]] = [] for c_idx, content in enumerate(content_clusters): cluster_kind = "figure" if content.boxclass == "picture" else "table" for b_idx, cap in enumerate(caption_blocks): if cap.kind != cluster_kind: # 类型过滤:防 figure↔table 串台 continue cx0, cy0, cx1, cy1 = cap.bbox h_overlap = min(content.x1, cx1) - max(content.x0, cx0) min_width = min(content.x1 - content.x0, cx1 - cx0) if min_width <= 0 or h_overlap < min_width * 0.25: continue if cy1 <= content.y0: # 标题在内容上方 v_gap = content.y0 - cy1 elif cy0 >= content.y1: # 标题在内容下方 v_gap = cy0 - content.y1 else: continue # 重叠,跳过 if v_gap > _CAPTION_MATCH_DISTANCE: continue candidates.append((v_gap, c_idx, b_idx)) # cluster 唯一归属最近的 caption;caption 可被多个 cluster 共享(复合图/子图) cluster_to_caption: dict[int, int] = {} for _score, c_idx, b_idx in sorted(candidates): if c_idx in cluster_to_caption: continue cluster_to_caption[c_idx] = b_idx # 聚合 caption → clusters,按页面位置排序保证稳定的合并/渲染顺序 caption_to_clusters: dict[int, list[int]] = {} for c_idx, b_idx in cluster_to_caption.items(): caption_to_clusters.setdefault(b_idx, []).append(c_idx) for b_idx in caption_to_clusters: caption_to_clusters[b_idx].sort( key=lambda i: (content_clusters[i].y0, content_clusters[i].x0) ) return caption_to_clusters def _absorb_stragglers( clusters: list[_BoxCluster], caption_blocks: list[_CaptionBlock], caption_matches: dict[int, list[int]], ) -> None: """把配不到 caption、却紧邻某已配 cluster 的同类型游离碎片并入该 cluster 所属 caption。 多面板图常把主 caption 只放在最下方,离 caption 过远(> _CAPTION_MATCH_DISTANCE) 的上方面板会被正常配对漏掉(如 Figure 4 的 (a) 子图距主标题 270pt)。这里把它们 并入紧邻的、已归属某 caption 的同类型 cluster,两道护栏避免误并: 1. 垂直紧邻(≤ _ABSORB_GAP)且水平重叠(同一列)——排除不同列的无关图; 2. 两者之间不得夹其他 caption ——有 caption 即另一张图/表的边界(如 Table 7/8 之间夹 Table 7 标题),不并,从而不破坏表格的独立配对。 """ # cluster_idx → caption_idx(已配 cluster 的反向索引) cluster_to_cap: dict[int, int] = {} for cap_idx, idxs in caption_matches.items(): for c_idx in idxs: cluster_to_cap[c_idx] = cap_idx # 多趟扫描直到稳定:游离面板可能链式排列(上图→中图→下图→caption), # 单趟只能吸收紧邻已配 cluster 的那一层;中图被并入后才轮到上图,故需重复。 changed = True while changed: changed = False for u_idx, u in enumerate(clusters): if u_idx in cluster_to_cap: continue # 已配,无需吸收 best_cap: int | None = None best_gap: float | None = None for p_idx, cap_idx in cluster_to_cap.items(): p = clusters[p_idx] if p.boxclass != u.boxclass: continue # 类型不同(figure vs table)不并 gap = max(0.0, max(u.y0, p.y0) - min(u.y1, p.y1)) if gap > _ABSORB_GAP: continue # 护栏 1:垂直不紧邻 if min(u.x1, p.x1) - max(u.x0, p.x0) <= 0: continue # 护栏 1:水平不重叠,非同一列 # 护栏 2:u 与 p 之间的垂直区间不得夹任何 caption between_lo = min(u.y1, p.y1) between_hi = max(u.y0, p.y0) if any( not (cb.bbox[3] < between_lo or cb.bbox[1] > between_hi) for cb in caption_blocks ): continue if best_gap is None or gap < best_gap: best_gap, best_cap = gap, cap_idx if best_cap is not None: caption_matches[best_cap].append(u_idx) cluster_to_cap[u_idx] = best_cap # 标记已并入,供后续趟链式吸收 changed = True # ── Phase 1: 检测 + 渲染 ────────────────────────────────────────────── def _render_box( page, box: _BoxCluster, images_dest: Path, filename: str, cap_type: str, page_num: int, caption_bbox: list[float] | None = None, ) -> bool: """渲染单个 box 区域并保存 JPEG,成功返回 True。 若提供 caption_bbox,则将内容与标题区域合并后一起截取, 使同一张截图同时包含图/表及其完整标题。 """ page_width = page.rect.width page_height = page.rect.height x0, y0, x1, y1 = box.x0, box.y0, box.x1, box.y1 if caption_bbox is not None: cx0, cy0, cx1, cy1 = caption_bbox x0 = min(x0, cx0) y0 = min(y0, cy0) x1 = max(x1, cx1) y1 = max(y1, cy1) clip = pymupdf.Rect( max(0, x0 - _REGION_PADDING), max(0, y0 - _REGION_PADDING), min(page_width, x1 + _REGION_PADDING), min(page_height, y1 + _REGION_PADDING), ) mat = pymupdf.Matrix(_RENDER_ZOOM, _RENDER_ZOOM) try: pix = page.get_pixmap(matrix=mat, clip=clip) except Exception: return False (images_dest / filename).write_bytes(pix.tobytes("jpeg", jpg_quality=92)) return True def _process_page( doc, page_idx: int, page_boxes: list[LayoutBox], images_dest: Path, manifest: dict, seen_labels: set, arxiv_id: str, ) -> int: """处理单页:检测内容 box → 文本定位 caption → 只渲染配到标题的。 配到 Figure/Table caption 的 box 用 caption 自带 ID 命名(figure_3.jpg); 同一 caption 的多个 cluster(复合图/子图被 DocLayout 切散)合并 bbox 整张截取; 没配到标题的(Algorithm 伪代码、无编号附录表、误检碎片)一律过滤,不输出。 """ page = doc[page_idx] page_num = page_idx + 1 # 收集本页 figure/table 内容 box(跳过极小区域;caption 改由文本定位,不收 box) raw_boxes = [] for box in page_boxes: if box.boxclass in ("table", "picture"): w = box.x1 - box.x0 h = box.y1 - box.y0 if w < 20 or h < 20 or w * h < _MIN_BOX_AREA: continue raw_boxes.append(box) if not raw_boxes: return 0 # 聚类:将同一 figure/table 的碎片 box 合并;用 PDF 文本定位 caption clusters = _cluster_boxes(raw_boxes) caption_blocks = _find_caption_blocks(page) # caption_idx → [cluster_idx, ...];一个 caption 可含多个子图 cluster(复合图) caption_matches = _pair_caption_blocks(clusters, caption_blocks) # 把距主 caption 过远的游离面板(如多面板图上方的 (a) 子图)并入紧邻的已配 cluster _absorb_stragglers(clusters, caption_blocks, caption_matches) extracted = 0 for cap_idx, cluster_indices in caption_matches.items(): cap = caption_blocks[cap_idx] if cap.id in seen_labels: continue # 同一图表被 DocLayout 切成多块重复检测,跳过后续 seen_labels.add(cap.id) # 同一 caption 的所有 cluster 合并 bbox,复合图/子图整张截取 members = [clusters[i] for i in cluster_indices] merged = _BoxCluster(members) filename = f"{cap.id.replace(' ', '_').lower()}.jpg" if not _render_box( page, merged, images_dest, filename, cap.kind, page_num, caption_bbox=cap.bbox, ): continue info = { "page": page_num, "type": cap.kind, "label": cap.id, "box": _cluster_to_box(merged), "caption_text": cap.text[:500], "caption_box": cap.bbox, "caption_source": "text", } if len(members) > 1: info["subfigure_count"] = len(members) manifest[filename] = info extracted += 1 return extracted # ── Phase 1 核心入口 ─────────────────────────────────────────────────── def extract_images_from_pdf(arxiv_id: str, pdf_path: Path | None = None) -> int: """Phase 1: 从 PDF 提取 Figure/Table 截图,生成通用标签的 manifest。 Args: arxiv_id: 论文 ID pdf_path: PDF 路径,默认 data/tmp/{arxiv_id}/paper.pdf Returns: 提取的图片数量 """ if pdf_path is None: pdf_path = TMP_DIR / arxiv_id / "paper.pdf" if not pdf_path.exists(): logger.warning("PDF not found for %s: %s", arxiv_id, pdf_path) return 0 images_dest = paper_dir(arxiv_id) / "images" images_dest.mkdir(parents=True, exist_ok=True) # 清理上次提取的旧图片 for old_file in images_dest.iterdir(): if old_file.suffix.lower() in (".png", ".jpg", ".jpeg"): old_file.unlink() if (images_dest / "manifest.json").exists(): (images_dest / "manifest.json").unlink() with pymupdf.open(str(pdf_path)) as doc: extracted = 0 manifest: dict[str, dict] = {} seen_labels: set[str] = set() for page_idx in range(doc.page_count): try: page_boxes = detect_page_layout(doc[page_idx]) extracted += _process_page( doc, page_idx, page_boxes, images_dest=images_dest, manifest=manifest, seen_labels=seen_labels, arxiv_id=arxiv_id, ) except Exception: logger.warning( "Failed to process page %d for %s", page_idx + 1, arxiv_id, exc_info=True, ) continue # 保存 manifest manifest_path = images_dest / "manifest.json" manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2)) if extracted > 0: logger.info( "Extracted %d figure/table screenshots from PDF for %s", extracted, arxiv_id, ) return extracted # ── Figure ↔ Image 关联 ──────────────────────────────────────────────── def _normalize_figure_id(raw_id: str) -> str: """归一化 Figure/Table ID:'Figure 1'/'Fig.1' → 'Figure 1'。""" m = re.match(r"(?:Fig\.?|Figure)\s*(\d+)", raw_id, re.IGNORECASE) if m: return f"Figure {m.group(1)}" m2 = re.match(r"Table\s*(\d+)", raw_id, re.IGNORECASE) if m2: return f"Table {m2.group(1)}" return raw_id def _is_figure_type(fig_id: str) -> bool: """判断是否为 Figure 类型(非 Table)。""" return not re.match(r"Table\s*(\d+)", fig_id, re.IGNORECASE) def _image_sort_key(name: str) -> tuple[int, int]: """按文件名中的编号排序提取的图片。""" # 新格式:figure_1.jpg, table_1.jpg m = re.search(r"(?:figure|table)_(\d+)", name) if m: return (0, int(m.group(1))) return (0, 0) def link_figures_with_images( figures: list[dict], images: list[dict], arxiv_id: str ) -> list[dict]: """将 summary figures 元数据与提取的图片文件关联。 策略: 1. 优先用 manifest.json 的 label 做 ID 精确匹配 2. 未匹配的 figure 用序号兜底:第 N 个 Figure → 第 N 张提取图 """ if not figures or not images: return figures manifest_path = PAPERS_DIR / arxiv_id / "images" / "manifest.json" # ── 策略 1:manifest ID 精确匹配 ── id_to_url: dict[str, str] = {} if manifest_path.exists(): try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except (ValueError, TypeError): manifest = {} for filename, info in manifest.items(): url = f"/papers/{arxiv_id}/images/{filename}" # 优先用 label 字段(新格式) label = info.get("label", "") if label: id_to_url[label] = url # 也兼容 figures/tables 列表(旧格式) for fig_id in info.get("figures", []) + info.get("tables", []): if fig_id not in id_to_url: id_to_url[fig_id] = url for fig in figures: raw_id = fig.get("id", "") normalized = _normalize_figure_id(raw_id) if normalized in id_to_url: fig["image_url"] = id_to_url[normalized] # ── 策略 2:序号兜底(manifest 匹配不到时) ── unmatched = [f for f in figures if not f.get("image_url")] if not unmatched: return figures # 已被策略 1(精确匹配)占用的图片不参与兜底,否则会把已正确归属的图复用给 # 别的条目(如缺失的 Table 4 误链到 Table 1 的截图)。 assigned_urls = {f["image_url"] for f in figures if f.get("image_url")} # 按类型分流:Figure vs Table fig_type_unmatched = [f for f in unmatched if _is_figure_type(f.get("id", ""))] table_type_unmatched = [ f for f in unmatched if not _is_figure_type(f.get("id", "")) ] # 剩余未占用图片按类型分流,按文件名中的编号排序 fig_images = sorted( [ img for img in images if "table" not in img["name"].lower() and img["url"] not in assigned_urls ], key=lambda img: _image_sort_key(img["name"]), ) table_images = sorted( [ img for img in images if "table" in img["name"].lower() and img["url"] not in assigned_urls ], key=lambda img: _image_sort_key(img["name"]), ) for i, fig in enumerate(fig_type_unmatched): if i < len(fig_images): fig["image_url"] = fig_images[i]["url"] for i, fig in enumerate(table_type_unmatched): if i < len(table_images): fig["image_url"] = table_images[i]["url"] return figures