| """ |
| pdf2ppt_with_sam workflow |
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 基于 slides PDF: |
| 1. 将 PDF 每页渲染为 PNG |
| 2. 对每页图片用 PaddleOCR 做文字 OCR |
| 3. 对每页图片用 MinerU 做版面分析(区分 Text vs Image/Table) |
| 4. 对每页图片用 SAM 做图标 / 图块分割 |
| 5. 智能合并: |
| - MinerU 划定 "图表区" (Image/Table) 和 "正文区"。 |
| - OCR 文本如果落在 "图表区" 则丢弃,防止图片上的文字重复生成。 |
| - SAM 图块如果落在 "图表区" 则丢弃(由 MinerU 负责);如果在 "正文区" 且包含文字则丢弃(防止把文字当图); |
| 剩下的 SAM 块被视为 "无字图标",进行抠图后保留。 |
| - MinerU 提取的图片直接复用其 sub_images 目录,不再手动裁剪。 |
| - 字体归一化:全局统计正文和标题字号,强制统一,保证整齐。 |
| - 使用 AI Inpainting 生成干净背景。 |
| """ |
|
|
| from __future__ import annotations |
| import os |
| import asyncio |
| from pathlib import Path |
| from typing import List, Dict, Any, Optional |
| from collections import Counter |
|
|
| import cv2 |
| import numpy as np |
| import fitz |
| import yaml |
| from PIL import Image |
|
|
| from dataflow_agent.workflow.registry import register |
| from dataflow_agent.graphbuilder.graph_builder import GenericGraphBuilder |
| from dataflow_agent.logger import get_logger |
|
|
| from dataflow_agent.state import Paper2FigureState |
| from dataflow_agent.utils import get_project_root |
|
|
| |
| from dataflow_agent.toolkits.imtool.sam_tool import segment_layout_boxes, segment_layout_boxes_server, free_sam_model |
| from dataflow_agent.toolkits.imtool.bg_tool import local_tool_for_bg_remove, free_bg_rm_model |
| from dataflow_agent.toolkits.imtool.mineru_tool import recursive_mineru_layout |
| from dataflow_agent.toolkits.imtool.req_img import gemini_multi_image_edit_async |
| from dataflow_agent.toolkits.imtool import ppt_tool |
|
|
| from pptx import Presentation |
| from pptx.util import Inches, Pt |
| from pptx.dml.color import RGBColor |
|
|
| log = get_logger(__name__) |
|
|
| |
| def load_server_config(): |
| root = get_project_root() |
| config_path = root / "conf" / "model_servers.yaml" |
| if not config_path.exists(): |
| log.warning(f"Config file not found at {config_path}, using defaults.") |
| return {} |
| try: |
| with open(config_path, "r") as f: |
| return yaml.safe_load(f) or {} |
| except Exception as e: |
| log.error(f"Failed to load config: {e}") |
| return {} |
|
|
| SERVER_CONFIG = load_server_config() |
|
|
| |
| def get_sam_urls(): |
| |
| if os.environ.get("SAM_SERVER_URLS"): |
| return os.environ.get("SAM_SERVER_URLS").split(",") |
| |
| |
| sam_cfg = SERVER_CONFIG.get("sam", {}) |
| instances = sam_cfg.get("instances", []) |
| if instances: |
| urls = [] |
| for inst in instances: |
| for port in inst.get("ports", []): |
| urls.append(f"http://127.0.0.1:{port}") |
| if urls: |
| return urls |
| |
| |
| return ["http://localhost:8021", "http://localhost:8022"] |
|
|
| def get_ocr_urls(): |
| |
| if os.environ.get("OCR_SERVER_URLS"): |
| return os.environ.get("OCR_SERVER_URLS").split(",") |
|
|
| |
| ocr_cfg = SERVER_CONFIG.get("ocr", {}) |
| if ocr_cfg: |
| host = ocr_cfg.get("host", "0.0.0.0") |
| if host == "0.0.0.0": host = "127.0.0.1" |
| port = ocr_cfg.get("port", 8003) |
| return [f"http://{host}:{port}"] |
|
|
| |
| return ["http://localhost:8003"] |
|
|
| SAM_SERVER_URLS = get_sam_urls() |
| OCR_SERVER_URLS = get_ocr_urls() |
|
|
|
|
| def _ensure_result_path(state: Paper2FigureState) -> str: |
| """ |
| 为本次 pdf2ppt_with_sam workflow 创建统一的输出目录: |
| - 如果 state.result_path 已存在,直接使用; |
| - 否则使用项目根目录下 outputs/pdf2ppt_with_sam/<timestamp>。 |
| """ |
| raw = getattr(state, "result_path", None) |
| if raw: |
| return raw |
|
|
| root = get_project_root() |
| ts = int(__import__("time").time()) |
| base_dir = (root / "outputs" / "pdf2ppt_with_sam" / str(ts)).resolve() |
| base_dir.mkdir(parents=True, exist_ok=True) |
| state.result_path = str(base_dir) |
| return state.result_path |
|
|
|
|
| def _run_sam_on_pages(image_paths: List[str], base_dir: str) -> List[Dict[str, Any]]: |
| """ |
| 对每一页图片运行 SAM,输出 layout_items。 |
| """ |
| results: List[Dict[str, Any]] = [] |
| sam_ckpt = f"{get_project_root()}/sam_b.pt" |
|
|
| for page_idx, img_path in enumerate(image_paths): |
| img_path_obj = Path(img_path) |
| if not img_path_obj.exists(): |
| log.warning(f"[pdf2ppt_with_sam] image not found for SAM: {img_path}") |
| results.append({"page_idx": page_idx, "layout_items": []}) |
| continue |
|
|
| out_dir = Path(base_dir) / "layout_items" / f"page_{page_idx+1:03d}" |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| try: |
| layout_items = segment_layout_boxes_server( |
| image_path=str(img_path_obj), |
| output_dir=str(out_dir), |
| server_urls=SAM_SERVER_URLS, |
| checkpoint=sam_ckpt, |
| min_area=200, |
| min_score=0.0, |
| iou_threshold=0.4, |
| top_k=25, |
| nms_by="mask", |
| ) |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam] Remote SAM failed: {e}. Fallback to local.") |
| |
| layout_items = segment_layout_boxes( |
| image_path=str(img_path_obj), |
| output_dir=str(out_dir), |
| checkpoint=sam_ckpt, |
| min_area=200, |
| min_score=0.0, |
| iou_threshold=0.4, |
| top_k=25, |
| nms_by="mask", |
| ) |
| |
| log.info(f"[pdf2ppt_with_sam][page#{page_idx+1}] SAM found {len(layout_items)} items") |
|
|
| |
| try: |
| pil_img = Image.open(str(img_path_obj)) |
| w, h = pil_img.size |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam][page#{page_idx+1}] open image failed: {e}") |
| w, h = 1024, 768 |
|
|
| for it in layout_items: |
| bbox = it.get("bbox") |
| if bbox and len(bbox) == 4: |
| x1n, y1n, x2n, y2n = bbox |
| x1 = int(round(x1n * w)) |
| y1 = int(round(y1n * h)) |
| x2 = int(round(x2n * w)) |
| y2 = int(round(y2n * h)) |
| if x2 > x1 and y2 > y1: |
| it["bbox_px"] = [x1, y1, x2, y2] |
|
|
| results.append({"page_idx": page_idx, "layout_items": layout_items}) |
|
|
| |
| try: |
| free_sam_model(checkpoint=sam_ckpt) |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam] free_sam_model failed: {e}") |
|
|
| return results |
|
|
|
|
| @register("pdf2ppt_parallel") |
| def create_pdf2ppt_with_sam_graph() -> GenericGraphBuilder: |
| """ |
| Workflow factory: dfa run --wf pdf2ppt_with_sam |
| """ |
| builder = GenericGraphBuilder(state_model=Paper2FigureState, entry_point="_start_") |
|
|
| |
| |
| |
|
|
| def _init_result_path(state: Paper2FigureState) -> Paper2FigureState: |
| _ensure_result_path(state) |
| return state |
|
|
| async def pdf_to_images_node(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 将 PDF 每一页渲染为 PNG。 |
| """ |
| pdf_path = getattr(state, "pdf_file", None) |
| if not pdf_path: |
| log.error("[pdf2ppt_with_sam] state.pdf_file is empty") |
| return state |
|
|
| base_dir = Path(_ensure_result_path(state)) |
| img_dir = base_dir / "slides_png" |
| image_paths = ppt_tool.pdf_to_images(pdf_path, str(img_dir)) |
| state.slide_images = image_paths |
| return state |
|
|
| async def slides_ocr_node(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 对每一页图片用 PaddleOCR 做 OCR。 |
| 使用 asyncio.to_thread 包装同步调用,避免阻塞事件循环。 |
| """ |
| image_paths: List[str] = getattr(state, "slide_images", []) or [] |
| if not image_paths: |
| log.error("[pdf2ppt_with_sam] no slide_images for OCR") |
| return state |
|
|
| def _sync_ocr_all_pages(): |
| """同步执行所有页面的 OCR""" |
| ocr_pages: List[Dict[str, Any]] = [] |
| for page_idx, img_path in enumerate(image_paths): |
| try: |
| |
| try: |
| result = ppt_tool.paddle_ocr_page_with_layout_server(img_path, server_urls=OCR_SERVER_URLS) |
| except Exception as e: |
| log.warning(f"[pdf2ppt_with_sam][OCR] remote failed: {e}. Fallback to local.") |
| result = ppt_tool.paddle_ocr_page_with_layout(img_path) |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam][OCR] page#{page_idx+1} failed: {e}") |
| result = { |
| "image_size": None, |
| "lines": [], |
| "body_h_px": None, |
| "bg_color": None, |
| "path": img_path, |
| "page_idx": page_idx, |
| } |
| result["page_idx"] = page_idx |
| result["path"] = img_path |
| ocr_pages.append(result) |
| return ocr_pages |
|
|
| |
| ocr_pages = await asyncio.to_thread(_sync_ocr_all_pages) |
| state.ocr_pages = ocr_pages |
| return state |
|
|
| async def slides_mineru_node(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 对每一页 PNG 使用 MinerU 做版面识别: |
| - 输出每页的 mineru_items,包含 type / bbox(norm) / text 等 |
| """ |
| image_paths: List[str] = getattr(state, "slide_images", []) or [] |
| if not image_paths: |
| log.error("[pdf2ppt_with_sam] no slide_images for MinerU") |
| return state |
|
|
| base_dir = Path(_ensure_result_path(state)) |
| mineru_dir = base_dir / "mineru_pages" |
| mineru_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| |
| port = getattr(getattr(state, "request", None), "mineru_port", 8010) |
| |
| max_depth = getattr(state, "mask_detail_level", 3) |
|
|
| mineru_pages: List[Dict[str, Any]] = [] |
|
|
| for page_idx, img_path in enumerate(image_paths): |
| try: |
| out_dir = mineru_dir / f"page_{page_idx+1:03d}" |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| log.critical(f"【mineru node】: {out_dir}") |
|
|
| mineru_items = await recursive_mineru_layout( |
| image_path=str(img_path), |
| port=port, |
| max_depth=3, |
| output_dir=str(out_dir), |
| ) |
| |
| |
| |
| |
| |
| mineru_pages.append({ |
| "page_idx": page_idx, |
| "blocks": mineru_items, |
| "path": img_path, |
| "mineru_output_dir": str(out_dir) |
| }) |
| log.info(f"[pdf2ppt_with_sam][MinerU] page#{page_idx+1} got {len(mineru_items)} blocks") |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam][MinerU] page#{page_idx+1} failed: {e}") |
| mineru_pages.append({ |
| "page_idx": page_idx, |
| "blocks": [], |
| "path": img_path, |
| }) |
|
|
| state.mineru_pages = mineru_pages |
|
|
| log.critical(f"[state.mineru_pages]: {state.mineru_pages}") |
|
|
| return state |
|
|
| async def slides_sam_node(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 对每一页图片运行 SAM 用于图标 / 图块分割。 |
| 使用 asyncio.to_thread 包装同步调用,避免阻塞事件循环。 |
| """ |
| image_paths: List[str] = getattr(state, "slide_images", []) or [] |
| if not image_paths: |
| log.error("[pdf2ppt_with_sam] no slide_images for SAM") |
| return state |
|
|
| base_dir = _ensure_result_path(state) |
| |
| |
| sam_pages = await asyncio.to_thread(_run_sam_on_pages, image_paths, base_dir) |
| state.sam_pages = sam_pages |
| return state |
|
|
| async def slides_layout_bg_remove_node(state: Paper2FigureState, sam_pages: List[Dict[str, Any]] = None) -> Paper2FigureState: |
| """ |
| 对每一页 SAM layout PNG 做背景抠图: |
| - 输入: state.sam_pages[*].layout_items[].png_path 或传入的 sam_pages |
| - 输出: 为每个 layout_item 写入 fg_png_path(抠完背景的 PNG) |
| 使用 asyncio.to_thread 包装同步调用,避免阻塞事件循环。 |
| """ |
| |
| if sam_pages is None: |
| sam_pages = getattr(state, "sam_pages", []) or [] |
| |
| if not sam_pages: |
| log.error("[pdf2ppt_with_sam] no sam_pages for bg remove") |
| return state |
|
|
| base_dir = Path(_ensure_result_path(state)) |
| icons_dir = base_dir / "sam_icons" |
| icons_dir.mkdir(parents=True, exist_ok=True) |
|
|
| model_path = getattr(getattr(state, "request", None), "bg_rm_model", None) |
|
|
| def _sync_bg_remove(): |
| """同步执行所有背景移除""" |
| processed = 0 |
| for p in sam_pages: |
| page_idx = p.get("page_idx", 0) |
| for it in p.get("layout_items", []): |
| png_path = it.get("png_path") |
| if not png_path or not os.path.exists(png_path): |
| continue |
| |
| |
| try: |
| |
| original_stem = Path(png_path).stem |
| |
| output_filename = f"page_{page_idx+1:03d}_{original_stem}_bg_removed.png" |
| output_path = icons_dir / output_filename |
| |
| req = { |
| "image_path": png_path, |
| "output_dir": str(icons_dir), |
| } |
| if model_path: |
| req["model_path"] = model_path |
| |
| fg_path = local_tool_for_bg_remove(req) |
| |
| |
| if fg_path and os.path.exists(fg_path): |
| |
| fg_path_obj = Path(fg_path) |
| if fg_path_obj.name != output_filename: |
| new_fg_path = fg_path_obj.parent / output_filename |
| fg_path_obj.rename(new_fg_path) |
| fg_path = str(new_fg_path) |
| |
| it["fg_png_path"] = fg_path |
| else: |
| it["fg_png_path"] = png_path |
| |
| processed += 1 |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam][bg_rm] failed for {png_path}: {e}") |
| it["fg_png_path"] = png_path |
|
|
| |
| try: |
| if model_path: |
| free_bg_rm_model(model_path=model_path) |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam] free_bg_rm_model failed: {e}") |
| |
| return processed |
|
|
| |
| processed = await asyncio.to_thread(_sync_bg_remove) |
|
|
| log.info(f"[pdf2ppt_with_sam] bg remove processed: {processed} items") |
| |
| |
| state.sam_pages = sam_pages |
| return state |
|
|
| |
| |
| |
| async def parallel_processing_node(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 并行执行三个分支: |
| 1. slides_ocr_node -> ocr_pages |
| 2. slides_mineru_node -> mineru_pages |
| 3. slides_sam_node + slides_layout_bg_remove_node -> sam_pages |
| |
| 三个分支共享 state.slide_images 作为输入,各自写入不同的输出字段。 |
| """ |
| import copy |
| import time |
| |
| log.info("[parallel_processing] 开始并行处理 OCR / MinerU / SAM+BgRemove ...") |
| start_time = time.time() |
| |
| |
| async def ocr_branch(): |
| """OCR 分支""" |
| log.info("[parallel_processing][OCR] 分支启动") |
| branch_state = copy.copy(state) |
| result = await slides_ocr_node(branch_state) |
| log.info(f"[parallel_processing][OCR] 分支完成,提取了 {len(getattr(result, 'ocr_pages', []))} 页") |
| return ("ocr", result) |
| |
| async def mineru_branch(): |
| """MinerU 分支""" |
| log.info("[parallel_processing][MinerU] 分支启动") |
| branch_state = copy.copy(state) |
| result = await slides_mineru_node(branch_state) |
| log.info(f"[parallel_processing][MinerU] 分支完成,提取了 {len(getattr(result, 'mineru_pages', []))} 页") |
| return ("mineru", result) |
| |
| async def sam_branch(): |
| """SAM + 背景移除 分支(串行执行)""" |
| log.info("[parallel_processing][SAM] 分支启动") |
| branch_state = copy.copy(state) |
| |
| |
| branch_state = await slides_sam_node(branch_state) |
| sam_pages = getattr(branch_state, "sam_pages", []) |
| log.info(f"[parallel_processing][SAM] SAM 完成,提取了 {len(sam_pages)} 页") |
| |
| |
| branch_state = await slides_layout_bg_remove_node(branch_state, sam_pages=sam_pages) |
| log.info("[parallel_processing][SAM] 背景移除完成") |
| |
| return ("sam", branch_state) |
| |
| |
| results = await asyncio.gather( |
| ocr_branch(), |
| mineru_branch(), |
| sam_branch(), |
| return_exceptions=True |
| ) |
| |
| |
| for r in results: |
| if isinstance(r, Exception): |
| log.error(f"[parallel_processing] 分支执行失败: {r}") |
| import traceback |
| traceback.print_exc() |
| continue |
| |
| branch_name, branch_state = r |
| |
| if branch_name == "ocr": |
| ocr_pages = getattr(branch_state, "ocr_pages", None) |
| if ocr_pages: |
| state.ocr_pages = ocr_pages |
| log.info(f"[parallel_processing] 合并 OCR 结果: {len(ocr_pages)} 页") |
| |
| elif branch_name == "mineru": |
| mineru_pages = getattr(branch_state, "mineru_pages", None) |
| if mineru_pages: |
| state.mineru_pages = mineru_pages |
| log.info(f"[parallel_processing] 合并 MinerU 结果: {len(mineru_pages)} 页") |
| |
| elif branch_name == "sam": |
| sam_pages = getattr(branch_state, "sam_pages", None) |
| if sam_pages: |
| state.sam_pages = sam_pages |
| log.info(f"[parallel_processing] 合并 SAM 结果: {len(sam_pages)} 页") |
| |
| elapsed = time.time() - start_time |
| log.info(f"[parallel_processing] 并行处理完成,耗时 {elapsed:.2f}s") |
| |
| return state |
|
|
| async def slides_ppt_generation_node(state: Paper2FigureState) -> Paper2FigureState: |
| """ |
| 结合 MinerU + OCR + SAM 结果生成可编辑 PPT: |
| |
| 改进点: |
| 1. MinerU 图片渲染修复:优先复用 MinerU 输出目录下的 sub_images,无法匹配时再手动裁剪。 |
| 2. 字体归一化: |
| - 统计全页正文(Body)文本的平均字号,取众数作为标准正文字号。 |
| - 标题(Title)字号设为标准正文的 1.5 倍(或取 Title 众数)。 |
| - 强制所有 Body 文本使用 Standard Body Font,所有 Title 文本使用 Standard Title Font。 |
| 3. 背景生成开关: |
| - 使用 state.use_ai_edit 控制是否调用 AI 生成纯净背景; |
| - 关闭时直接使用纯白背景。 |
| 4. 并行 API 调用: |
| - 将 Inpainting API 调用改为并行执行,加快多页处理速度。 |
| """ |
| |
| ocr_pages: List[Dict[str, Any]] = getattr(state, "ocr_pages", []) or [] |
| sam_pages: List[Dict[str, Any]] = getattr(state, "sam_pages", []) or [] |
| mineru_pages: List[Dict[str, Any]] = getattr(state, "mineru_pages", []) or [] |
|
|
| if not ocr_pages: |
| log.error("[pdf2ppt_with_sam] no ocr_pages, abort PPT generation") |
| return state |
|
|
| |
| sam_dict = {p.get("page_idx", 0): p.get("layout_items", []) for p in sam_pages} |
| |
| |
| |
| mineru_dict = {} |
| for p in mineru_pages: |
| pid = p.get("page_idx", 0) |
| mineru_dict[pid] = p |
| mineru_dict[str(pid)] = p |
|
|
| |
| prs = Presentation() |
| prs.slide_width = Inches(ppt_tool.SLIDE_W_IN) |
| prs.slide_height = Inches(ppt_tool.SLIDE_H_IN) |
| slide_w_emu = prs.slide_width |
| slide_h_emu = prs.slide_height |
|
|
| |
| base_dir = Path(_ensure_result_path(state)) |
|
|
| |
| |
| |
| async def _call_image_api_with_retry(coro_factory, retries: int = 3, delay: float = 1.0) -> bool: |
| """ |
| 对图像生成/编辑进行最多 retries 次重试。 |
| """ |
| last_err: Optional[Exception] = None |
| for attempt in range(1, retries + 1): |
| try: |
| await coro_factory() |
| return True |
| except Exception as e: |
| last_err = e |
| log.error(f"[pdf2ppt_with_sam] image api failed attempt {attempt}/{retries}: {e}") |
| if attempt < retries: |
| try: |
| await asyncio.sleep(delay) |
| except Exception: |
| pass |
| log.error(f"[pdf2ppt_with_sam] image api failed after {retries} attempts: {last_err}") |
| return False |
|
|
| |
| |
| |
| def _bbox_area(bbox): |
| return max(0, bbox[2] - bbox[0]) * max(0, bbox[3] - bbox[1]) |
|
|
| def _get_intersection_area(bbox1, bbox2): |
| x1 = max(bbox1[0], bbox2[0]) |
| y1 = max(bbox1[1], bbox2[1]) |
| x2 = min(bbox1[2], bbox2[2]) |
| y2 = min(bbox1[3], bbox2[3]) |
| return max(0, x2 - x1) * max(0, y2 - y1) |
|
|
| def _is_inside(inner, outer, threshold=0.9): |
| inter = _get_intersection_area(inner, outer) |
| inner_a = _bbox_area(inner) |
| if inner_a <= 0: return False |
| return (inter / inner_a) >= threshold |
|
|
| def _is_overlap(bbox1, bbox2, threshold=0.1): |
| inter = _get_intersection_area(bbox1, bbox2) |
| min_area = min(_bbox_area(bbox1), _bbox_area(bbox2)) |
| if min_area <= 0: return False |
| return (inter / min_area) >= threshold |
|
|
| |
| |
| |
| |
| pages_render_data: List[Dict[str, Any]] = [] |
| ai_coroutines = [] |
| |
| |
| for pinfo in ocr_pages: |
| page_idx = pinfo.get("page_idx", 0) |
| |
| |
| mineru_page_data = mineru_dict.get(page_idx) |
| if not mineru_page_data: |
| mineru_page_data = mineru_dict.get(str(page_idx), {}) |
| if mineru_page_data: |
| log.warning(f"[pdf2ppt_with_sam] page_idx mismatch fixed by str conversion: {page_idx}") |
| |
| img_path = pinfo.get("path") |
| lines = pinfo.get("lines", []) |
| |
| if not img_path or not os.path.exists(img_path): |
| log.warning(f"[pdf2ppt_with_sam] missing img for page#{page_idx+1}: {img_path}") |
| continue |
|
|
| |
| try: |
| pil_img = Image.open(img_path) |
| w0, h0 = pil_img.size |
| except Exception as e: |
| log.error(f"Failed to open image {img_path}: {e}") |
| continue |
|
|
| |
| |
| |
| mineru_blocks = mineru_page_data.get("blocks", []) |
| mineru_out_dir = mineru_page_data.get("mineru_output_dir") |
| |
| image_zones = [] |
| |
| |
| sub_images_dir = None |
| sub_images_dirs: List[Path] = [] |
| if mineru_out_dir: |
| try: |
| page_root = Path(mineru_out_dir) |
| direct = page_root / "sub_images" |
| if direct.exists() and direct.is_dir(): |
| sub_images_dirs.append(direct) |
| for d in page_root.rglob("sub_images"): |
| if d.is_dir(): |
| sub_images_dirs.append(d) |
| seen = set() |
| unique_dirs: List[Path] = [] |
| for d in sub_images_dirs: |
| rp = str(d.resolve()) |
| if rp not in seen: |
| seen.add(rp) |
| unique_dirs.append(d) |
| for d in unique_dirs: |
| pngs = list(d.glob("*.png")) |
| if pngs: |
| sub_images_dir = d |
| break |
| if sub_images_dir: |
| sub_files = sorted([p.name for p in sub_images_dir.glob("*.png")]) |
| log.info(f"[pdf2ppt_with_sam][page#{page_idx+1}] MinerU sub_images dir: {sub_images_dir}, found {len(sub_files)} pngs") |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam][page#{page_idx+1}] search sub_images failed: {e}") |
|
|
| for idx, blk in enumerate(mineru_blocks): |
| btype = (blk.get("type") or "").lower() |
| bbox = blk.get("bbox") |
| if not bbox or len(bbox) != 4: |
| continue |
| |
| x1 = int(round(bbox[0] * w0)) |
| y1 = int(round(bbox[1] * h0)) |
| x2 = int(round(bbox[2] * w0)) |
| y2 = int(round(bbox[3] * h0)) |
| |
| if x2 <= x1 or y2 <= y1: continue |
| px_bbox = [x1, y1, x2, y2] |
| |
| is_image_zone = btype in ['image', 'figure', 'table', 'formula'] |
| img_path_found = None |
| |
| if is_image_zone: |
| if blk.get("img_path") and os.path.exists(blk["img_path"]): |
| img_path_found = blk["img_path"] |
| |
| if not img_path_found and sub_images_dir: |
| try: |
| depth = blk.get("depth", 0) |
| try: |
| depth = int(depth) |
| except Exception: |
| depth = 0 |
| prefix = f"depth{depth}_blk{idx}_" |
| for f in sorted(sub_images_dir.glob("*.png")): |
| if f.name.startswith(prefix): |
| img_path_found = str(f.resolve()) |
| break |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam][page#{page_idx+1}] match sub_images failed: {e}") |
|
|
| if not img_path_found: |
| fallback_dir = base_dir / "mineru_fallback_crops" / f"page_{page_idx+1:03d}" |
| fallback_dir.mkdir(parents=True, exist_ok=True) |
| save_path = fallback_dir / f"mineru_{idx}_{btype}.png" |
| try: |
| if not save_path.exists(): |
| crop = pil_img.crop((x1, y1, x2, y2)) |
| crop.save(save_path) |
| img_path_found = str(save_path) |
| except Exception as e: |
| log.error(f"Failed to crop mineru block {idx}: {e}") |
|
|
| if img_path_found: |
| image_zones.append({ |
| "bbox": px_bbox, |
| "type": btype, |
| "img_path": img_path_found |
| }) |
|
|
| |
| |
| |
| final_ocr_lines = [] |
| |
| for line in lines: |
| l_bbox, l_text, l_conf = line |
| is_in_image = False |
| for zone in image_zones: |
| if _is_inside(l_bbox, zone["bbox"]): |
| is_in_image = True |
| break |
| |
| if not is_in_image: |
| l_type = "body" |
| for blk in mineru_blocks: |
| btype = (blk.get("type") or "").lower() |
| b_bbox = blk.get("bbox") |
| if not b_bbox: continue |
| bx1 = int(round(b_bbox[0] * w0)) |
| by1 = int(round(b_bbox[1] * h0)) |
| bx2 = int(round(b_bbox[2] * w0)) |
| by2 = int(round(b_bbox[3] * h0)) |
| |
| if btype in ['title', 'header'] and _is_inside(l_bbox, [bx1, by1, bx2, by2]): |
| l_type = "title" |
| break |
| |
| |
| raw_pt_obj = ppt_tool.estimate_font_pt(l_bbox, img_h_px=h0, body_h_px=None) |
| raw_pt = raw_pt_obj.pt if hasattr(raw_pt_obj, "pt") else raw_pt_obj |
|
|
| final_ocr_lines.append((l_bbox, l_text, l_conf, l_type, raw_pt)) |
|
|
| |
| |
| |
| raw_sam_items = sam_dict.get(page_idx, []) |
| final_sam_items = [] |
| |
| for item in raw_sam_items: |
| s_bbox = item.get("bbox_px") |
| if not s_bbox: continue |
| is_in_image = False |
| for zone in image_zones: |
| if _is_inside(s_bbox, zone["bbox"], threshold=0.6): |
| is_in_image = True |
| break |
| if is_in_image: continue |
|
|
| is_text_block = False |
| for line in final_ocr_lines: |
| l_bbox = line[0] |
| if _is_overlap(s_bbox, l_bbox, threshold=0.3) or _is_inside(l_bbox, s_bbox): |
| is_text_block = True |
| break |
| if is_text_block: continue |
|
|
| w = s_bbox[2] - s_bbox[0] |
| h = s_bbox[3] - s_bbox[1] |
| if w < 5 or h < 5: continue |
| if w*h < 400: continue |
| |
| final_sam_items.append(item) |
|
|
| |
| |
| |
| clean_bg_path = base_dir / "clean_backgrounds" / f"clean_bg_{page_idx+1:03d}.png" |
| clean_bg_path.parent.mkdir(parents=True, exist_ok=True) |
| |
| use_ai_bg = bool(getattr(state, "use_ai_edit", False)) |
| log.critical(f"[pdf2ppt 是否使用AI: ][page#{page_idx+1}] use_ai_bg={use_ai_bg}") |
| |
| ai_task = None |
| if use_ai_bg and os.path.exists(img_path): |
| try: |
| |
| ori_cv = cv2.imread(img_path) |
| if ori_cv is not None: |
| h_cv, w_cv = ori_cv.shape[:2] |
| mask_cv = np.zeros((h_cv, w_cv), dtype=np.uint8) |
| |
| |
| for line in final_ocr_lines: |
| bbox = line[0] |
| pad = 5 |
| mx1 = int(max(0, bbox[0] - pad)) |
| my1 = int(max(0, bbox[1] - pad)) |
| mx2 = int(min(w_cv, bbox[2] + pad)) |
| my2 = int(min(h_cv, bbox[3] + pad)) |
| cv2.rectangle(mask_cv, (mx1, my1), (mx2, my2), (255), -1) |
| |
| mask_path = base_dir / "masks" / f"mask_{page_idx+1:03d}.png" |
| mask_path.parent.mkdir(parents=True, exist_ok=True) |
| cv2.imwrite(str(mask_path), mask_cv) |
| |
| |
| req_cfg = getattr(state, "request", None) or {} |
| if not isinstance(req_cfg, dict): |
| req_cfg = req_cfg.__dict__ if hasattr(req_cfg, "__dict__") else {} |
| |
| api_key = req_cfg.get("api_key") or os.getenv("DF_API_KEY") |
| api_url = req_cfg.get("chat_api_url") or "https://api.apiyi.com" |
| model_name = req_cfg.get("gen_fig_model") or "gemini-3-pro-image-preview" |
|
|
| if api_key: |
| log.info(f"[pdf2ppt_with_sam][page#{page_idx+1}] Scheduling Gemini Inpainting...") |
| prompt = ( |
| "Use the second image as a mask to remove text from the first image. " |
| "Fill the removed text areas with background texture to make it clean. " |
| "Keep non-text areas (figures, tables) unchanged." |
| ) |
| |
| async def _run_ai_job(_p_idx=page_idx, _img_p=img_path, _mask_p=str(mask_path), _out_p=str(clean_bg_path)): |
| await _call_image_api_with_retry( |
| lambda: gemini_multi_image_edit_async( |
| prompt=prompt, |
| image_paths=[_img_p, _mask_p], |
| save_path=_out_p, |
| api_url=api_url, |
| api_key=api_key, |
| model=model_name, |
| resolution="1K", |
| timeout=300 |
| ) |
| ) |
| |
| ai_task = _run_ai_job() |
| ai_coroutines.append(ai_task) |
| else: |
| log.warning("Skipping AI edit: No API Key provided") |
| except Exception as e: |
| log.error(f"[pdf2ppt_with_sam][page#{page_idx+1}] Prepare AI task failed: {e}") |
|
|
| |
| pages_render_data.append({ |
| "page_idx": page_idx, |
| "scale_x": slide_w_emu / w0, |
| "scale_y": slide_h_emu / h0, |
| "clean_bg_path": str(clean_bg_path), |
| "image_zones": image_zones, |
| "final_sam_items": final_sam_items, |
| "final_ocr_lines": final_ocr_lines, |
| "ai_task": ai_task |
| }) |
|
|
| |
| |
| |
| |
| |
| use_global_clustering = getattr(state, "use_global_font_clustering", False) |
| global_clusterer = None |
| |
| if use_global_clustering: |
| log.info("[pdf2ppt_with_sam] Performing GLOBAL font size clustering...") |
| all_sizes = [] |
| for p_data in pages_render_data: |
| for line in p_data["final_ocr_lines"]: |
| |
| raw_pt = line[4] |
| if raw_pt and raw_pt > 0: |
| all_sizes.append(raw_pt) |
| |
| global_clusterer = ppt_tool.FontSizeClustering(n_clusters=3) |
| global_clusterer.fit(all_sizes) |
| |
| |
| if ai_coroutines: |
| log.info(f"[pdf2ppt_with_sam] Executing {len(ai_coroutines)} AI background tasks in parallel...") |
| start_t = __import__("time").time() |
| |
| await asyncio.gather(*ai_coroutines, return_exceptions=True) |
| cost = __import__("time").time() - start_t |
| log.info(f"[pdf2ppt_with_sam] AI tasks finished. cost={cost:.2f}s") |
|
|
| |
| |
| |
| for p_data in pages_render_data: |
| |
| scale_x = p_data["scale_x"] |
| scale_y = p_data["scale_y"] |
| clean_bg_path = p_data["clean_bg_path"] |
| image_zones = p_data["image_zones"] |
| final_sam_items = p_data["final_sam_items"] |
| final_ocr_lines = p_data["final_ocr_lines"] |
| |
| |
| if use_global_clustering: |
| clusterer = global_clusterer |
| else: |
| |
| page_sizes = [l[4] for l in final_ocr_lines if l[4] > 0] |
| clusterer = ppt_tool.FontSizeClustering(n_clusters=3) |
| clusterer.fit(page_sizes) |
|
|
| slide = prs.slides.add_slide(prs.slide_layouts[6]) |
|
|
| |
| bg_image_path_for_ppt = None |
| if os.path.exists(clean_bg_path): |
| bg_image_path_for_ppt = clean_bg_path |
| |
| if bg_image_path_for_ppt: |
| try: |
| slide.shapes.add_picture(bg_image_path_for_ppt, 0, 0, prs.slide_width, prs.slide_height) |
| except Exception as e: |
| log.error(f"Failed to set slide background image: {e}") |
| |
| bg = slide.background |
| fill = bg.fill |
| fill.solid() |
| fill.fore_color.rgb = RGBColor(255, 255, 255) |
| else: |
| bg = slide.background |
| fill = bg.fill |
| fill.solid() |
| fill.fore_color.rgb = RGBColor(255, 255, 255) |
|
|
| |
| for zone in image_zones: |
| ipath = zone["img_path"] |
| if not os.path.exists(ipath): |
| log.warning(f"MinerU image path not found: {ipath}") |
| continue |
| |
| bbox = zone["bbox"] |
| left = ppt_tool.px_to_emu(bbox[0], scale_x) |
| top = ppt_tool.px_to_emu(bbox[1], scale_y) |
| width = ppt_tool.px_to_emu(bbox[2] - bbox[0], scale_x) |
| height = ppt_tool.px_to_emu(bbox[3] - bbox[1], scale_y) |
| |
| try: |
| slide.shapes.add_picture(ipath, left, top, width, height) |
| except Exception as e: |
| log.error(f"Failed to add mineru image: {e}") |
|
|
| |
| for item in final_sam_items: |
| ipath = item.get("fg_png_path") or item.get("png_path") |
| if not ipath or not os.path.exists(ipath): continue |
| |
| bbox = item.get("bbox_px") |
| left = ppt_tool.px_to_emu(bbox[0], scale_x) |
| top = ppt_tool.px_to_emu(bbox[1], scale_y) |
| width = ppt_tool.px_to_emu(bbox[2] - bbox[0], scale_x) |
| height = ppt_tool.px_to_emu(bbox[3] - bbox[1], scale_y) |
| |
| try: |
| slide.shapes.add_picture(ipath, left, top, width, height) |
| except Exception as e: |
| log.error(f"Failed to add SAM icon: {e}") |
|
|
| |
| for line in final_ocr_lines: |
| bbox, text, conf, l_type, raw_pt = line |
| x1, y1, x2, y2 = bbox |
| if (x2 - x1) < 5 or (y2 - y1) < 5: continue |
|
|
| left = ppt_tool.px_to_emu(x1, scale_x) |
| top = ppt_tool.px_to_emu(y1, scale_y) |
| width = max(1, ppt_tool.px_to_emu(x2 - x1, scale_x)) |
| height = max(1, ppt_tool.px_to_emu(y2 - y1, scale_y)) |
|
|
| tb = slide.shapes.add_textbox(left, top, width, height) |
| tf = tb.text_frame |
| tf.clear() |
| tf.word_wrap = True |
| tb.fill.background() |
| tb.line.fill.background() |
|
|
| p = tf.paragraphs[0] |
| p.text = text |
| |
| |
| final_pt = clusterer.map(raw_pt) |
| p.font.size = Pt(final_pt) |
| |
| |
| if l_type == "title": |
| p.font.bold = True |
| |
| p.font.color.rgb = RGBColor(0, 0, 0) |
|
|
| |
| |
| ppt_path = base_dir / "pdf2ppt_with_sam_output.pptx" |
| prs.save(str(ppt_path)) |
| state.ppt_path = str(ppt_path) |
| log.info(f"[pdf2ppt_with_sam] PPT generated: {ppt_path}") |
|
|
| return state |
|
|
| nodes = { |
| "_start_": _init_result_path, |
| "pdf_to_images": pdf_to_images_node, |
| "parallel_processing": parallel_processing_node, |
| "slides_ppt_generation": slides_ppt_generation_node, |
| "_end_": lambda state: state, |
| } |
|
|
| edges = [ |
| ("pdf_to_images", "parallel_processing"), |
| ("parallel_processing", "slides_ppt_generation"), |
| ("slides_ppt_generation", "_end_"), |
| ] |
|
|
| builder.add_nodes(nodes).add_edges(edges) |
| builder.add_edge("_start_", "pdf_to_images") |
| return builder |
|
|