# -*- coding: utf-8 -*- """ ppt_tool 本模块将一组顺序图片通过 PaddleOCR 识别为可编辑文本,自动分析行高、版式和颜色,生成带“干净底图+覆盖文字框”的 PPTX,并可选同时导出 PDF; 内部提供多种参数控制背景 inpaint 强度(INPAINT_METHOD/INPAINT_RADIUS、SIMPLE_BG_VAR_THRESH、MASK_DILATE_ITER、USE_ADAPTIVE_MASK)、 OCR 分辨率与锐化(UPSCALE_LONG_SIDE_TO、UPSCALE_INTERP、ENABLE_SHARPEN、SHARPEN_AMOUNT)、 文本过滤阈值(DROP_SCORE)以及字号放大与标题/副标题对正文的比例(BASE_BODY_PT、FONT_SCALE_FACTOR、TITLE_RATIO_*/SUBTITLE_RATIO_*/BODY_RATIO_*), 并通过 ADD_BACKGROUND_IMAGE / CLEAN_BACKGROUND / EXTRACT_TEXT_COLOR 控制是否叠加背景图片、是否抠掉原文字、是否按原图估计文字颜色,从而在“还原视觉效果”与“可编辑性/美观度”和运行性能之间做平衡。 功能概述: - 从指定目录按自然顺序读取图片 - 生成包含所有图片页的 PDF 文件 - 使用 PaddleOCR 对页面进行 OCR,识别文本行 - 基于识别结果自动估计字体大小和颜色,将文本叠加到 PPTX 中 - 可选地对原始页面进行 inpaint,生成“去文字的干净底图”作为 PPT 背景 典型用法: - 在 DataFlow-Agent 的图像处理流程中,作为从图片页到可编辑 PPT 文稿的后处理工具 - 也可在其它组件或脚本中通过对外函数直接调用 """ # 函数一览: # natural_key(s): 生成用于文件名“自然排序”的 key,将数字部分按整数比较。 # list_images_in_dir(d): 按自然顺序列出目录中的所有图片文件路径。 # read_bgr(path): 以兼容非 ASCII 路径的方式读取图片,并返回标准 BGR uint8 格式。 # debug_dump(img, tag): 将中间图像写入调试目录并记录基础统计信息。 # images_to_pdf(image_paths, output_pdf_path): 将一组图片顺序导出为单个 PDF 文件。 # pdf_to_images(pdf_path, out_dir, dpi): 将 PDF 每一页按指定分辨率渲染为 PNG,并返回图片路径列表。 # upscale_if_needed(bgr, long_side_to, interp): 若分辨率偏低则按长边放大图像,返回放大后图像和缩放比例。 # sharpen(bgr, amount): 使用“反锐化掩模”方式对图像进行轻度锐化。 # preprocess_for_ocr(bgr): 对整页图像做放大与可选锐化,生成适合 OCR 的版本及缩放比例。 # is_cjk(s): 判断字符串中是否包含 CJK(中日韩)字符。 # iou(a, b): 计算两个矩形框的交并比(IoU)。 # merge_lines(lines, y_tol, x_gap): 将 OCR 的短行/单词按行方向与间距合并成句级文本行。 # text_score(lines): 根据字符数量、平均置信度及是否含 CJK,估计一组文本行的整体得分。 # paddle_ocr(bgr, drop_score): 调用 PaddleOCR 对整页 BGR 图像做 OCR,并按置信度阈值过滤结果。 # paddle_ocr_page_with_layout(img_path): 对单页图片做预处理 + OCR + 行合并 + 行高/背景色估计并返回布局信息。 # extract_text_color(bgr, bbox, bg_color): 从给定文字区域估计主文字颜色,尽量排除接近背景的颜色。 # estimate_background_color(bgr, lines): 用文字 mask 反选背景区域,估计页面主背景颜色。 # px_to_emu(px, emu_per_px): 将像素值按给定比例转换为 PPT 使用的 EMU 单位。 # analyze_line_heights(lines): 统计 OCR 行框高度分布,估计正文行高的中位数。 # classify_line_role(bbox, img_h_px, body_h_px): 根据行高与垂直位置粗略区分标题、副标题和正文。 # estimate_font_pt(bbox, img_h_px, body_h_px, slide_h_in): 依据原图行高比例估计在 PPT 中的字号大小。 # add_background(slide, bgr, slide_w_emu, slide_h_emu, tmp_path): 将整页背景图添加到 PPT 幻灯片并删除临时文件。 # build_text_mask_from_lines(bgr, lines): 基于 OCR 行框生成粗略的文字区域二值 mask。 # build_adaptive_mask(bgr, lines): 结合局部对比度与自适应阈值,生成更精细的文字主 mask。 # is_simple_background_region(bgr, mask): 判断文字区域邻域背景是否近似纯色(方差较小)。 # fill_with_neighbor(bgr, mask): 对复杂背景的文字区域先用邻域像素进行粗填充,缓解 inpaint 伪影。 # make_clean_background(bgr, lines): 基于文字 mask 和 inpaint 生成“去文字的干净底图”。 # ocr_images_to_ppt(image_paths, output_pptx, add_background_image, clean_background, use_text_color): 将图片序列通过 OCR 转成带背景与覆盖文本框的可编辑 PPT。 # images_to_pdf_and_ppt(image_paths, output_pdf_path, output_pptx_path, add_background_image, clean_background, extract_text_color): 将给定图片列表一站式转换为 PDF 和 PPTX 并返回路径。 # convert_images_dir_to_pdf_and_ppt(input_dir, output_pdf_path, output_pptx_path, add_background_image, clean_background, extract_text_color): 从图片目录读取图片并生成对应的 PDF + PPTX。 # convert_images_dir_to_pdf_and_ppt_api(input_dir, output_pdf_path, output_pptx_path, api_url, api_key, model, use_api_inpaint, add_background_image, clean_background, use_text_color): 异步版本的目录转 PDF/PPTX,优先使用图像编辑 API 做 inpainting,失败时回退到本地 inpaint。 import os import re from typing import Sequence, Optional, Dict, Any, List, Tuple import requests import random from collections import Counter import fitz # PyMuPDF from pathlib import Path import numpy as np from PIL import Image import cv2 try: # pragma: no cover from paddleocr import PaddleOCR # type: ignore except Exception: # pragma: no cover PaddleOCR = None # type: ignore from pptx import Presentation from pptx.util import Inches, Pt from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from dataflow_agent.utils import get_project_root from dataflow_agent.logger import get_logger from typing import Union log = get_logger(__name__) # ---------------------------- # Config (默认配置,可通过对外函数参数进行部分覆盖) # ---------------------------- ADD_BACKGROUND_IMAGE = True CLEAN_BACKGROUND = True # 是否尝试抠掉文字、生成无字底图再叠加OCR文字 EXTRACT_TEXT_COLOR = True # 是否提取原图文字颜色 INPAINT_METHOD = cv2.INPAINT_TELEA # or cv2.INPAINT_NS INPAINT_RADIUS = 7 # 增大修复半径(从3提高到7) SIMPLE_BG_VAR_THRESH = 50.0 # 放宽阈值(从12提高到50) MASK_DILATE_ITER = 2 # 增加膨胀次数(从1提高到2) USE_ADAPTIVE_MASK = True # 使用自适应mask生成 # 输出PPT比例(16:9) SLIDE_W_IN = 13.333 SLIDE_H_IN = 7.5 # Debug:落盘你送入 OCR 的图片,方便肉眼确认内容/分辨率/是否被处理坏 DEBUG_DUMP_FIRST_N = 2 DEBUG_DIR = f"{get_project_root()}/tests/debug_frames" # ---------- 核心修复:低分辨率页面的 OCR 前增强 ---------- UPSCALE_LONG_SIDE_TO = 2200 # 建议 2000~3200,越大越慢 UPSCALE_INTERP = cv2.INTER_CUBIC ENABLE_SHARPEN = True # 轻度锐化,提升边缘对比度 SHARPEN_AMOUNT = 0.8 # 0.0~1.5 之间 # 识别过滤阈值 DROP_SCORE = 30 # PaddleOCR 的score是 0-1,这里统一乘100后按0-100过滤 # 字号优化配置(放大整体字号,明显拉开标题对正文的差距) BASE_BODY_PT = 16.0 # 正文基准字号 FONT_SCALE_FACTOR = 1.0 # 全局字号缩放因子 TITLE_RATIO_MIN = 2.0 # 标题最小倍率 TITLE_RATIO_MAX = 3.5 # 标题最大倍率 SUBTITLE_RATIO_MIN = 1.4 # 副标题最小倍率 SUBTITLE_RATIO_MAX = 2.0 # 副标题最大倍率 BODY_RATIO_MIN = 0.9 # 正文最小倍率 BODY_RATIO_MAX = 1.1 # 正文最大倍率 # PaddleOCR 配置(全局只初始化一次) # NOTE: For deployments that only need PDF export (no editable PPTX), PaddleOCR can be absent. if PaddleOCR is not None: # pragma: no cover PADDLE_OCR = PaddleOCR( use_angle_cls=True, # 角度分类,处理横竖混排 lang="ch", # 中文 + 英文 ) else: PADDLE_OCR = None # ---------------------------- # Font Size Clustering # ---------------------------- class FontSizeClustering: """ 自适应字号聚类器:将连续的、有噪声的字号估算值,映射到 K 个离散的"标准字号"。 支持全局聚类(全文档统一)或单页聚类。 依赖 sklearn.cluster.KMeans,如果缺失则回退到简单的众数/分位数策略。 """ def __init__(self, n_clusters: int = 4, merge_tol: float = 2.0): self.n_clusters = n_clusters self.merge_tol = merge_tol self.centroids = [] self.has_sklearn = False try: from sklearn.cluster import KMeans self._KMeans = KMeans self.has_sklearn = True except ImportError: pass def fit(self, font_sizes: List[float]) -> "FontSizeClustering": """ 输入原始字号列表(pt),计算聚类中心。 """ # 过滤无效值 data = [x for x in font_sizes if x > 0] if not data: self.centroids = [12.0] # 默认回退 return self # 1. 如果数据量太少,直接用原始值(去重排序) if len(data) < self.n_clusters: self.centroids = sorted(list(set(data))) return self # 2. 如果没有 sklearn,回退到简单的直方图统计(取前K个高频值) if not self.has_sklearn: # 简单统计:取出现频率最高的 K 个,或者简单的分位数 # 这里用频率统计更符合"标准字号"的直觉 counts = Counter([round(x) for x in data]) top_k = counts.most_common(self.n_clusters) self.centroids = sorted([float(x[0]) for x in top_k]) return self # 3. K-Means 聚类 import numpy as np X = np.array(data).reshape(-1, 1) # 动态调整 K:不能超过样本唯一值的数量 n_unique = len(set([round(x, 1) for x in data])) real_k = min(self.n_clusters, n_unique) kmeans = self._KMeans(n_clusters=real_k, n_init=10, random_state=42) kmeans.fit(X) centers = sorted(kmeans.cluster_centers_.flatten()) # 4. 后处理:合并过近的中心 (Merge close centers) merged_centers = [] if centers: curr = centers[0] for next_c in centers[1:]: if (next_c - curr) < self.merge_tol: # 距离太近,合并(取平均) curr = (curr + next_c) / 2.0 else: merged_centers.append(curr) curr = next_c merged_centers.append(curr) # 圆整到 0.5 pt self.centroids = [round(c * 2) / 2.0 for c in merged_centers] log.info(f"[FontSizeClustering] Fitted centroids: {self.centroids}") return self def map(self, pt: float) -> float: """ 将原始字号映射到最近的中心。 """ if not self.centroids: return pt # 找最近邻 closest = min(self.centroids, key=lambda c: abs(c - pt)) return closest # ---------------------------- # IO helpers # ---------------------------- def natural_key(s: str): return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)] def list_images_in_dir(d: str) -> List[str]: """ 按自然顺序列出目录中所有图片文件路径。 """ exts = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") files = [f for f in os.listdir(d) if f.lower().endswith(exts)] files.sort(key=natural_key) return [os.path.join(d, f) for f in files] def read_bgr(path: str) -> np.ndarray: """ Robust image reader: - supports non-ascii paths (np.fromfile + imdecode) - returns BGR uint8 HxWx3 """ data = np.fromfile(path, dtype=np.uint8) img = cv2.imdecode(data, cv2.IMREAD_UNCHANGED) if img is None: img = cv2.imread(path, cv2.IMREAD_UNCHANGED) if img is None: raise ValueError(f"Failed to read image: {path}") # Normalize to BGR uint8 if img.ndim == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) elif img.ndim == 3 and img.shape[2] == 4: img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) if img.dtype != np.uint8: img = np.clip(img, 0, 255).astype(np.uint8) return img def debug_dump(img: np.ndarray, tag: str = "dbg") -> None: """ 将中间图像写入 DEBUG_DIR 方便调试。 """ os.makedirs(DEBUG_DIR, exist_ok=True) log.info(f"{tag} type: {type(img)}") if isinstance(img, np.ndarray): log.info( f"{tag} shape: {img.shape}, dtype: {img.dtype}, " f"min/max: {int(img.min())}/{int(img.max())}" ) out_path = os.path.join(DEBUG_DIR, f"{tag}.png") ok = cv2.imwrite(out_path, img) log.info(f"{tag} saved: {out_path}, ok: {ok}") # ---------------------------- # PDF / Page helpers # ---------------------------- def images_to_pdf(image_paths: Sequence[str], output_pdf_path: str) -> str: """ 将一组图片导出为单个 PDF 文件。 """ imgs: List[Image.Image] = [] for p in image_paths: im = Image.open(p) if im.mode != "RGB": im = im.convert("RGB") imgs.append(im) if not imgs: raise ValueError("No images for PDF.") imgs[0].save(output_pdf_path, save_all=True, append_images=imgs[1:]) return output_pdf_path def pdf_to_images(pdf_path: str, out_dir: str, dpi: int = 220) -> List[str]: """ 将 PDF 每一页渲染为 PNG 图片,返回图片路径列表(按页码顺序)。 参数 ---- pdf_path: 输入 PDF 文件路径。 out_dir: 输出图片所在目录,不存在会自动创建。 dpi: 渲染分辨率(每英寸像素数),默认 220。 返回 ---- List[str] 按页码顺序排列的 PNG 图片绝对路径列表。 """ doc = fitz.open(pdf_path) out_dir_path = Path(out_dir) out_dir_path.mkdir(parents=True, exist_ok=True) image_paths: List[str] = [] for page_index in range(len(doc)): page = doc.load_page(page_index) zoom = dpi / 72.0 mat = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=mat, alpha=False) img_path = out_dir_path / f"page_{page_index + 1:03d}.png" pix.save(str(img_path)) image_paths.append(str(img_path)) doc.close() log.info(f"[pdf_to_images] rendered {len(image_paths)} pages from {pdf_path}") return image_paths # ---------------------------- # Preprocess # ---------------------------- def upscale_if_needed( bgr: np.ndarray, long_side_to: int = UPSCALE_LONG_SIDE_TO, interp: int = UPSCALE_INTERP, ): h, w = bgr.shape[:2] long_side = max(h, w) if long_side >= long_side_to: return bgr, 1.0 scale = long_side_to / float(long_side) new_w = int(round(w * scale)) new_h = int(round(h * scale)) up = cv2.resize(bgr, (new_w, new_h), interpolation=interp) return up, scale def sharpen(bgr: np.ndarray, amount: float = SHARPEN_AMOUNT) -> np.ndarray: """ Unsharp mask style: sharpen = img*(1+a) - blur*a """ if amount <= 0: return bgr blur = cv2.GaussianBlur(bgr, (0, 0), sigmaX=1.2, sigmaY=1.2) out = cv2.addWeighted(bgr, 1.0 + amount, blur, -amount, 0) return np.clip(out, 0, 255).astype(np.uint8) def preprocess_for_ocr(bgr: np.ndarray): """ Make a "det-friendly" version of the page: - upscale to a reasonable working resolution - optional sharpen """ up, scale = upscale_if_needed(bgr) if ENABLE_SHARPEN: up = sharpen(up, amount=SHARPEN_AMOUNT) return up, scale # ---------------------------- # OCR helpers # ---------------------------- def is_cjk(s: str) -> bool: return any("\u4e00" <= ch <= "\u9fff" for ch in s) def iou(a, b) -> float: ax1, ay1, ax2, ay2 = a bx1, by1, bx2, by2 = b ix1, iy1 = max(ax1, bx1), max(ay1, by1) ix2, iy2 = min(ax2, bx2), min(ay2, by2) iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1) inter = iw * ih if inter <= 0: return 0.0 area_a = (ax2 - ax1) * (ay2 - ay1) area_b = (bx2 - bx1) * (by2 - by1) return inter / (area_a + area_b - inter + 1e-6) def merge_lines( lines: Sequence[Tuple[Sequence[float], str, float]], y_tol: int = 12, x_gap: int = 18 ): """ 将OCR的word/短行合并成句子级别的行 """ if not lines: return [] lines = sorted(lines, key=lambda x: (x[0][1], x[0][0])) def union(b1, b2): return [ min(b1[0], b2[0]), min(b1[1], b2[1]), max(b1[2], b2[2]), max(b1[3], b2[3]), ] merged = [] cur_bbox, cur_text, cur_conf_sum, cur_n = ( lines[0][0], lines[0][1], lines[0][2], 1, ) for bbox, text, conf in lines[1:]: cy1 = (cur_bbox[1] + cur_bbox[3]) / 2 cy2 = (bbox[1] + bbox[3]) / 2 same_line = abs(cy1 - cy2) <= y_tol near_x = (bbox[0] - cur_bbox[2]) <= x_gap if same_line and near_x: cur_bbox = union(cur_bbox, bbox) if (not is_cjk(cur_text)) and (not is_cjk(text)): cur_text = (cur_text + " " + text).strip() else: cur_text = (cur_text + text).strip() cur_conf_sum += conf cur_n += 1 else: merged.append((cur_bbox, cur_text, cur_conf_sum / cur_n)) cur_bbox, cur_text, cur_conf_sum, cur_n = bbox, text, conf, 1 merged.append((cur_bbox, cur_text, cur_conf_sum / cur_n)) return merged def text_score(lines) -> float: if not lines: return 0.0 total_chars = sum(len(t) for (_, t, _) in lines) avg_conf = sum(conf for (_, _, conf) in lines) / max(1, len(lines)) cjk_bonus = 1.1 if any(is_cjk(t) for (_, t, _) in lines) else 1.0 return total_chars * (avg_conf / 100.0) * cjk_bonus # normalize confidence to 0-1 def paddle_ocr(bgr: np.ndarray, drop_score: int = DROP_SCORE): """ 使用 PaddleOCR 识别整页图片 返回格式:[(bbox, text, confidence), ...] bbox: [x1, y1, x2, y2] 注意:这里直接在 BGR 图上跑,PaddleOCR 内部会处理颜色空间。 """ if PADDLE_OCR is None: raise RuntimeError( "PaddleOCR is not available. Install `paddleocr` + `paddlepaddle` to enable PPTX OCR export." ) h, w = bgr.shape[:2] # ocr_result: List[List[ [box, (text, score)], ... ]] ocr_result = PADDLE_OCR.ocr(bgr, cls=True) lines = [] if not ocr_result: return lines # 通常一页对应 ocr_result[0] for line in ocr_result[0]: box, (text, score) = line if not text: continue if score * 100.0 < drop_score: continue # box 是四点多边形:[ [x1,y1], [x2,y2], [x3,y3], [x4,y4] ] xs = [p[0] for p in box] ys = [p[1] for p in box] x1, y1, x2, y2 = min(xs), min(ys), max(xs), max(ys) # 边界裁剪 x1 = max(0, min(w - 1, x1)) x2 = max(0, min(w, x2)) y1 = max(0, min(h - 1, y1)) y2 = max(0, min(h, y2)) if x2 <= x1 or y2 <= y1: continue bbox = [float(x1), float(y1), float(x2), float(y2)] # 保持 0-100 置信度范围,和原 Tesseract 逻辑兼容 lines.append((bbox, text.strip(), float(score * 100.0))) return lines def paddle_ocr_page_with_layout(img_path: str) -> Dict[str, Any]: """ 对单页图片执行: - 读取 + 预处理(放大 + 锐化) - PaddleOCR 识别 - 坐标从 OCR 分辨率映射回原图 - 行合并 - 正文行高估计 - 背景颜色估计 返回: { "image_size": (w, h), "lines": [(bbox, text, conf), ...], # bbox 为原图像素坐标 "body_h_px": float 或 None, "bg_color": (r,g,b) 或 None, } """ bgr = read_bgr(img_path) h0, w0 = bgr.shape[:2] # 预处理 ocr_img, scale = preprocess_for_ocr(bgr) h1, w1 = ocr_img.shape[:2] log.info(f"[paddle_ocr_page_with_layout] {os.path.basename(img_path)} up-scale={scale:.3f}") # OCR raw_lines = paddle_ocr(ocr_img) # 映射回原图像素坐标 if raw_lines and (w1 != w0 or h1 != h0): sx = w0 / float(w1) sy = h0 / float(h1) raw_lines = [ ([b[0] * sx, b[1] * sy, b[2] * sx, b[3] * sy], t, c) for (b, t, c) in raw_lines ] # 合并行 y_tol = max(12, int(h0 * 0.008)) x_gap = max(18, int(w0 * 0.01)) lines = merge_lines(raw_lines, y_tol=y_tol, x_gap=x_gap) # 正文行高估计 body_h_px = analyze_line_heights(lines) if not lines: log.warning(f"[paddle_ocr_page_with_layout] no text detected: {img_path}") bg_color = None else: log.info( f"[paddle_ocr_page_with_layout] detected {len(lines)} text boxes, body_h_px={body_h_px}" ) bg_color = estimate_background_color(bgr, lines) if EXTRACT_TEXT_COLOR else None return { "image_size": (w0, h0), "lines": lines, "body_h_px": body_h_px, "bg_color": bg_color, } def paddle_ocr_page_with_layout_server( img_path: str, server_urls: Union[str, List[str]], ) -> Dict[str, Any]: """ 对单页图片执行远程 OCR 处理。 参数: img_path: 图片路径 server_urls: OCR 服务器 URL 或 URL 列表 返回: 同 paddle_ocr_page_with_layout """ if isinstance(server_urls, str): urls = [server_urls] else: urls = list(server_urls) if not urls: raise ValueError("No server URLs provided") base_url = random.choice(urls) api_url = f"{base_url.rstrip('/')}/predict" abs_img_path = os.path.abspath(img_path) payload = {"image_path": abs_img_path} try: response = requests.post(api_url, json=payload, timeout=300) response.raise_for_status() data = response.json() # Transform lines back to tuples if needed, though list is fine # lines: [[bbox, text, conf], ...] -> [(bbox, text, conf), ...] lines = [] for line_obj in data.get("lines", []): lines.append(( line_obj.get("bbox"), line_obj.get("text"), line_obj.get("conf") )) return { "image_size": tuple(data.get("image_size", [0, 0])), "lines": lines, "body_h_px": data.get("body_h_px"), "bg_color": tuple(data.get("bg_color")) if data.get("bg_color") else None } except Exception as e: raise RuntimeError(f"Failed to call OCR server at {api_url}: {e}") # ---------------------------- # Color extraction # ---------------------------- def extract_text_color( bgr: np.ndarray, bbox, bg_color=None ) -> Tuple[int, int, int]: """ 从文字区域提取主色调 返回 (r, g, b) 元组 """ x1, y1, x2, y2 = [int(round(v)) for v in bbox] h, w = bgr.shape[:2] # 边界检查 x1 = max(0, min(w - 1, x1)) x2 = max(0, min(w, x2)) y1 = max(0, min(h - 1, y1)) y2 = max(0, min(h, y2)) if x2 <= x1 or y2 <= y1: return (0, 0, 0) # 默认黑色 # 提取文字区域 region = bgr[y1:y2, x1:x2] if region.size == 0: return (0, 0, 0) # 转换为RGB region_rgb = cv2.cvtColor(region, cv2.COLOR_BGR2RGB) pixels = region_rgb.reshape(-1, 3) # 如果像素太少,直接返回中位数颜色 if len(pixels) < 10: median_color = np.median(pixels, axis=0).astype(int) return tuple(int(x) for x in median_color) # 使用K-means聚类找出主色调(2-3个聚类) try: from sklearn.cluster import KMeans n_clusters = min(3, len(pixels)) kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10) kmeans.fit(pixels) # 获取聚类中心和每个聚类的像素数 centers = kmeans.cluster_centers_ labels = kmeans.labels_ counts = np.bincount(labels) # 如果提供了背景色,排除接近背景色的聚类 if bg_color is not None: bg_array = np.array(bg_color) valid_centers = [] valid_counts = [] for i, center in enumerate(centers): # 计算与背景色的距离 dist = np.linalg.norm(center - bg_array) if dist > 30: # 距离阈值 valid_centers.append(center) valid_counts.append(counts[i]) if valid_centers: centers = np.array(valid_centers) counts = np.array(valid_counts) # 选择出现频率最高的颜色 dominant_idx = np.argmax(counts) dominant_color = centers[dominant_idx].astype(int) return tuple(int(x) for x in dominant_color) except Exception: # 如果sklearn不可用或出错,使用简单的中位数方法 median_color = np.median(pixels, axis=0).astype(int) return tuple(int(x) for x in median_color) def estimate_background_color(bgr: np.ndarray, lines): """ 估计背景主色调,用于颜色提取时排除背景 """ h, w = bgr.shape[:2] # 创建文字mask mask = np.ones((h, w), dtype=np.uint8) * 255 for bbox, _, _ in lines: x1, y1, x2, y2 = [int(round(v)) for v in bbox] x1 = max(0, min(w - 1, x1)) x2 = max(0, min(w, x2)) y1 = max(0, min(h - 1, y1)) y2 = max(0, min(h, y2)) if x2 > x1 and y2 > y1: mask[y1:y2, x1:x2] = 0 # 提取背景区域像素 bg_pixels = bgr[mask > 0] if bg_pixels.size == 0: return None # 转换为RGB并计算中位数 bg_rgb = cv2.cvtColor( bg_pixels.reshape(-1, 1, 3), cv2.COLOR_BGR2RGB ).reshape(-1, 3) median_bg = np.median(bg_rgb, axis=0).astype(int) return tuple(int(x) for x in median_bg) # ---------------------------- # PPT helpers # ---------------------------- def px_to_emu(px: float, emu_per_px: float) -> int: return int(px * emu_per_px) def analyze_line_heights(lines) -> Optional[float]: """ 统计行高分布,估计"正文行高" """ if not lines: return None hs = [max(1, b[3] - b[1]) for (b, _, _) in lines] return float(np.median(hs)) def classify_line_role(bbox, img_h_px: int, body_h_px: Optional[float]) -> str: """ 大致区分:title / subtitle / body """ x1, y1, x2, y2 = bbox h = max(1, y2 - y1) if body_h_px is None or body_h_px <= 0: return "body" ratio = h / float(body_h_px) # 位置辅助:靠近页面顶部 + 较高行 y_center = (y1 + y2) / 2.0 top_region = img_h_px * 0.3 if ratio > 1.7 and y_center < top_region: return "title" if ratio > 1.3: return "subtitle" return "body" def estimate_font_pt( bbox, img_h_px: int, body_h_px: Optional[float], slide_h_in: float = SLIDE_H_IN ): """ 根据行高按比例估计字号(改进版:移除硬编码倍率限制) 核心思路: 1. 计算原图中该行的像素高度 2. 按比例映射到PPT的点数(pt) 3. 不再强制限制标题/副标题的倍率范围 """ x1, y1, x2, y2 = bbox h_px = max(1, y2 - y1) # 方法:将像素高度按图片高度比例转换为PPT点数 # PPT高度 = 7.5英寸 = 540pt (1英寸=72pt) slide_h_pt = slide_h_in * 72.0 # 行高占图片高度的比例 height_ratio = h_px / float(img_h_px) # 映射到PPT点数,乘以0.7是经验系数(因为行高通常大于字号) pt = slide_h_pt * height_ratio * 0.7 # 应用全局缩放因子 pt *= FONT_SCALE_FACTOR # 只做合理范围限制,不再强制角色倍率 return Pt(max(8, min(96, pt))) def add_background( slide, bgr: np.ndarray, slide_w_emu: int, slide_h_emu: int, tmp_path: str ) -> None: rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) pil = Image.fromarray(rgb) pil.save(tmp_path) slide.shapes.add_picture(tmp_path, 0, 0, width=slide_w_emu, height=slide_h_emu) os.remove(tmp_path) def build_text_mask_from_lines(bgr: np.ndarray, lines) -> np.ndarray: """ 根据OCR行框生成初始mask(粗略矩形) """ h, w = bgr.shape[:2] mask = np.zeros((h, w), dtype=np.uint8) for bbox, text, conf in lines: x1, y1, x2, y2 = [int(round(v)) for v in bbox] x1 = max(0, min(w - 1, x1)) x2 = max(0, min(w, x2)) y1 = max(0, min(h - 1, y1)) y2 = max(0, min(h, y2)) if x2 <= x1 or y2 <= y1: continue mask[y1:y2, x1:x2] = 255 if MASK_DILATE_ITER > 0: kernel = np.ones((3, 3), np.uint8) mask = cv2.dilate(mask, kernel, iterations=MASK_DILATE_ITER) return mask def build_adaptive_mask(bgr: np.ndarray, lines) -> np.ndarray: """ 使用自适应方法生成更精细的文字主mask 结合OCR bbox和实际文字形状(内部边缘 + 阈值) """ h, w = bgr.shape[:2] mask = np.zeros((h, w), dtype=np.uint8) gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) for bbox, text, conf in lines: x1, y1, x2, y2 = [int(round(v)) for v in bbox] x1 = max(0, min(w - 1, x1)) x2 = max(0, min(w, x2)) y1 = max(0, min(h - 1, y1)) y2 = max(0, min(h, y2)) if x2 <= x1 or y2 <= y1: continue region = gray[y1:y2, x1:x2] if region.size == 0: continue try: # 先看局部对比度 if np.var(region) < 100: # 对比度很低,优先用 Canny 边缘找笔画 edges = cv2.Canny(region, 50, 150) kernel = np.ones((2, 2), np.uint8) binary = cv2.dilate(edges, kernel, iterations=1) else: # 对比度正常,用阈值法 if region.shape[0] < 20 or region.shape[1] < 20: _, binary = cv2.threshold( region, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) binary = 255 - binary # 反色:文字为白 else: binary = cv2.adaptiveThreshold( region, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2, ) kernel = np.ones((2, 2), np.uint8) binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) mask[y1:y2, x1:x2] = cv2.bitwise_or(mask[y1:y2, x1:x2], binary) except Exception: mask[y1:y2, x1:x2] = 255 if MASK_DILATE_ITER > 0: kernel = np.ones((3, 3), np.uint8) mask = cv2.dilate(mask, kernel, iterations=MASK_DILATE_ITER) return mask def is_simple_background_region(bgr: np.ndarray, mask: np.ndarray) -> bool: """ 简单判定:mask 区域附近背景是否接近纯色(方差较小) """ gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # 扩大一点范围取邻域 dilated = cv2.dilate((mask > 0).astype(np.uint8), np.ones((5, 5), np.uint8), iterations=1) region = gray[dilated > 0] if region.size == 0: return False var = float(np.var(region)) return var < SIMPLE_BG_VAR_THRESH def fill_with_neighbor(bgr: np.ndarray, mask: np.ndarray) -> np.ndarray: """ 对复杂背景时,优先用邻域像素粗略填充,再交给 inpaint 做平滑, 避免 NS/TELEA 在大块区域产生奇怪纹理。 """ result = bgr.copy() h, w = mask.shape for y in range(h): xs = np.where(mask[y] > 0)[0] if len(xs) == 0: continue x_min, x_max = xs[0], xs[-1] left_src = max(0, x_min - 3) right_src = min(w - 1, x_max + 3) fill_color = ( (bgr[y, left_src].astype(np.int32) + bgr[y, right_src].astype(np.int32)) // 2 ).astype(np.uint8) result[y, x_min : x_max + 1] = fill_color return result def make_clean_background(bgr: np.ndarray, lines) -> np.ndarray: """ 使用改进的 inpaint 生成“无字版底图”: - 自适应主文字 mask - 扩展阴影/发光区域 mask 只用于 inpaint - 简单背景直接 inpaint,复杂背景先邻域填充再小半径 inpaint """ if not lines: return bgr # 使用自适应或简单mask(主文字区域) if USE_ADAPTIVE_MASK: main_mask = build_adaptive_mask(bgr, lines) else: main_mask = build_text_mask_from_lines(bgr, lines) # 扩展阴影/发光区域,inpaint 时用这个大 mask shadow_mask = cv2.dilate(main_mask, np.ones((7, 7), np.uint8), iterations=2) is_simple = is_simple_background_region(bgr, shadow_mask) if is_simple: # 简单背景:直接 inpaint + 轻微模糊 clean = cv2.inpaint(bgr, shadow_mask, INPAINT_RADIUS, cv2.INPAINT_TELEA) else: # 复杂背景:先用邻域像素粗填,再用小半径 NS 微调 prefilled = fill_with_neighbor(bgr, shadow_mask) clean = cv2.inpaint( prefilled, shadow_mask, max(3, INPAINT_RADIUS // 2), cv2.INPAINT_NS ) clean = cv2.GaussianBlur(clean, (3, 3), 0.5) # 只在 shadow_mask 区域应用 inpaint 结果 result = bgr.copy() mask_3ch = cv2.cvtColor(shadow_mask, cv2.COLOR_GRAY2BGR) / 255.0 result = (clean * mask_3ch + bgr * (1 - mask_3ch)).astype(np.uint8) return result def ocr_images_to_ppt( image_paths: Sequence[str], output_pptx: str, add_background_image: bool = ADD_BACKGROUND_IMAGE, clean_background: bool = CLEAN_BACKGROUND, use_text_color: bool = EXTRACT_TEXT_COLOR, ) -> str: """ 将图片通过OCR转换为可编辑文字的PPT(优化版) 注意:该函数为内部实现,推荐通过 images_to_pdf_and_ppt / convert_images_dir_to_pdf_and_ppt 间接调用。 """ prs = Presentation() prs.slide_width = Inches(SLIDE_W_IN) prs.slide_height = Inches(SLIDE_H_IN) slide_w_emu = prs.slide_width slide_h_emu = prs.slide_height for idx, img_path in enumerate(image_paths, start=1): log.info(f"Processing slide #{idx}: {os.path.basename(img_path)}") bgr = read_bgr(img_path) # 预处理:放大和锐化 ocr_img, scale = preprocess_for_ocr(bgr) if idx <= DEBUG_DUMP_FIRST_N: debug_dump(bgr, f"before_ocr_raw_{idx}") debug_dump(ocr_img, f"before_ocr_up_{idx}") log.info(f"slide#{idx} upscale scale={scale:.3f}") h0, w0 = bgr.shape[:2] # 原图尺寸 h1, w1 = ocr_img.shape[:2] # OCR输入尺寸 # 创建幻灯片 slide = prs.slides.add_slide(prs.slide_layouts[6]) # 空白布局 # OCR识别(PaddleOCR 直接处理 BGR 图像) lines = paddle_ocr(ocr_img) # 把bbox从OCR图坐标缩回原图坐标 if lines and (w1 != w0 or h1 != h0): sx = w0 / float(w1) sy = h0 / float(h1) lines = [ ([b[0] * sx, b[1] * sy, b[2] * sx, b[3] * sy], t, c) for (b, t, c) in lines ] # 再合并一轮 y_tol = max(12, int(h0 * 0.008)) x_gap = max(18, int(w0 * 0.01)) lines = merge_lines(lines, y_tol=y_tol, x_gap=x_gap) # 统计正文行高,用于后续字号估计 body_h_px = analyze_line_heights(lines) if not lines: log.warning(f"slide#{idx} no text detected") else: log.info(f"slide#{idx} detected {len(lines)} text boxes") # 估计背景颜色(用于颜色提取) bg_color = None if use_text_color and lines: bg_color = estimate_background_color(bgr, lines) if bg_color: log.info(f"slide#{idx} estimated background color: RGB{bg_color}") # 底图处理:可选 inpaint 生成"干净底图" bg_for_slide = bgr if add_background_image: if clean_background and lines: log.info(f"slide#{idx} applying inpainting...") bg_for_slide = make_clean_background(bgr, lines) if idx <= DEBUG_DUMP_FIRST_N: debug_dump(bg_for_slide, f"clean_bg_{idx}") tmp = f"__ppt_bg_{idx}.png" add_background(slide, bg_for_slide, slide_w_emu, slide_h_emu, tmp) scale_x = slide_w_emu / w0 scale_y = slide_h_emu / h0 for bbox, text, conf in lines: x1, y1, x2, y2 = bbox if (x2 - x1) < 6 or (y2 - y1) < 6: continue # 计算字号 font_size = estimate_font_pt(bbox, img_h_px=h0, body_h_px=body_h_px) # 文本框尺寸:直接使用OCR检测到的bbox尺寸 bbox_width_emu = px_to_emu((x2 - x1), scale_x) bbox_height_emu = px_to_emu((y2 - y1), scale_y) width = bbox_width_emu height = bbox_height_emu left = px_to_emu(x1, scale_x) top = px_to_emu(y1, scale_y) # 添加透明文本框 tb = slide.shapes.add_textbox(left, top, int(width), int(height)) tf = tb.text_frame tf.clear() tf.word_wrap = False # 禁用自动换行 # 垂直居中(可选) tf.vertical_anchor = MSO_ANCHOR.MIDDLE # 设置文本框透明 tb.fill.background() # 无填充 tb.line.fill.background() # 无边框 p = tf.paragraphs[0] p.text = text # 使用原生分散对齐:多字符分散,单字符居中 if len(text) > 1: p.alignment = PP_ALIGN.DISTRIBUTE else: p.alignment = PP_ALIGN.CENTER # 不再依赖 font.spacing 模拟分散,统一设为 0 p.font.size = font_size p.font.spacing = Pt(0) # 提取并设置文字颜色 if use_text_color: text_color = extract_text_color(bgr, bbox, bg_color) p.font.color.rgb = RGBColor(*text_color) else: # 默认黑色 p.font.color.rgb = RGBColor(0, 0, 0) prs.save(output_pptx) return output_pptx # ---------------------------- # Public API # ---------------------------- def images_to_pdf_and_ppt( image_paths: Sequence[str], output_pdf_path: Optional[str] = None, output_pptx_path: Optional[str] = None, add_background_image: bool = ADD_BACKGROUND_IMAGE, clean_background: bool = CLEAN_BACKGROUND, extract_text_color: bool = EXTRACT_TEXT_COLOR, ) -> Dict[str, Optional[str]]: """ 将给定的一组图片转换为 PDF 和可编辑 PPTX。 参数: image_paths: 按页面顺序排列的图片路径列表。 output_pdf_path: 输出 PDF 文件路径,若为 None 则不生成 PDF。 output_pptx_path: 输出 PPTX 文件路径,若为 None 则不生成 PPT。 add_background_image: 是否在 PPT 中加入整页背景图。 clean_background: 是否对背景进行 inpaint 处理(在 add_background_image 为 True 时生效)。 extract_text_color: 是否根据原图估计文字颜色,用于 PPT 文本着色。 返回: 包含已生成文件路径的字典,例如: { "pdf": "/path/to/output.pdf" 或 None, "pptx": "/path/to/output_editable.pptx" 或 None, } """ result: Dict[str, Optional[str]] = {"pdf": None, "pptx": None} if output_pdf_path is not None: result["pdf"] = images_to_pdf(image_paths, output_pdf_path) if output_pptx_path is not None: result["pptx"] = ocr_images_to_ppt( image_paths=image_paths, output_pptx=output_pptx_path, add_background_image=add_background_image, clean_background=clean_background, use_text_color=extract_text_color, ) return result def convert_images_dir_to_pdf_and_ppt( input_dir: str, output_pdf_path: Optional[str] = None, output_pptx_path: Optional[str] = None, add_background_image: bool = ADD_BACKGROUND_IMAGE, clean_background: bool = CLEAN_BACKGROUND, extract_text_color: bool = EXTRACT_TEXT_COLOR, ) -> Dict[str, Optional[str]]: """ 给定图片目录,自动读取所有图片并生成 PDF + PPTX。 参数: input_dir: 包含图片的目录,内部按文件名自然排序。 其余参数同 images_to_pdf_and_ppt。 返回: 同 images_to_pdf_and_ppt。 """ image_paths = list_images_in_dir(input_dir) if not image_paths: raise ValueError(f"No images found in {input_dir!r}") return images_to_pdf_and_ppt( image_paths=image_paths, output_pdf_path=output_pdf_path, output_pptx_path=output_pptx_path, add_background_image=add_background_image, clean_background=clean_background, extract_text_color=extract_text_color, ) async def convert_images_dir_to_pdf_and_ppt_api( input_dir: str, output_pdf_path: Optional[str] = None, output_pptx_path: Optional[str] = None, api_url: Optional[str] = None, api_key: Optional[str] = None, model: Optional[str] = None, use_api_inpaint: bool = True, add_background_image: bool = ADD_BACKGROUND_IMAGE, clean_background: bool = CLEAN_BACKGROUND, use_text_color: bool = EXTRACT_TEXT_COLOR, ) -> Dict[str, Optional[str]]: """ 带 API inpainting 支持的图片转 PDF/PPTX 函数(异步版本) 与 convert_images_dir_to_pdf_and_ppt 的区别: - 支持使用图像编辑 API 进行 inpainting(优先) - API 失败时自动 fallback 到传统 OpenCV inpaint - 支持重试机制(最多3次) 参数: input_dir: 包含图片的目录,内部按文件名自然排序 output_pdf_path: 输出 PDF 文件路径,若为 None 则不生成 PDF output_pptx_path: 输出 PPTX 文件路径,若为 None 则不生成 PPT api_url: 图像编辑 API 的 URL api_key: API 密钥 model: 使用的模型名称 use_api_inpaint: 是否启用 API inpainting(默认 True) add_background_image: 是否在 PPT 中加入整页背景图 clean_background: 是否对背景进行 inpaint 处理 extract_text_color: 是否根据原图估计文字颜色 返回: 包含已生成文件路径的字典 """ import asyncio from dataflow_agent.toolkits.imtool.req_img import generate_or_edit_and_save_image_async image_paths = list_images_in_dir(input_dir) if not image_paths: raise ValueError(f"No images found in {input_dir!r}") result: Dict[str, Optional[str]] = {"pdf": None, "pptx": None} # 生成 PDF if output_pdf_path is not None: result["pdf"] = images_to_pdf(image_paths, output_pdf_path) # 生成 PPTX(带 API inpainting 支持) if output_pptx_path is not None: prs = Presentation() prs.slide_width = Inches(SLIDE_W_IN) prs.slide_height = Inches(SLIDE_H_IN) slide_w_emu = prs.slide_width slide_h_emu = prs.slide_height for idx, img_path in enumerate(image_paths, start=1): log.info(f"Processing slide #{idx}: {os.path.basename(img_path)}") bgr = read_bgr(img_path) ocr_img, scale = preprocess_for_ocr(bgr) h0, w0 = bgr.shape[:2] h1, w1 = ocr_img.shape[:2] slide = prs.slides.add_slide(prs.slide_layouts[6]) # OCR 识别 lines = paddle_ocr(ocr_img) # 坐标映射 if lines and (w1 != w0 or h1 != h0): sx = w0 / float(w1) sy = h0 / float(h1) lines = [ ([b[0] * sx, b[1] * sy, b[2] * sx, b[3] * sy], t, c) for (b, t, c) in lines ] # 合并行 y_tol = max(12, int(h0 * 0.008)) x_gap = max(18, int(w0 * 0.01)) lines = merge_lines(lines, y_tol=y_tol, x_gap=x_gap) body_h_px = analyze_line_heights(lines) bg_color = estimate_background_color(bgr, lines) if use_text_color and lines else None # 底图处理:优先使用 API inpainting bg_for_slide = bgr if add_background_image: if clean_background and lines and use_api_inpaint and api_url and api_key and model: # 使用 API inpainting(带重试) async def _call_inpaint_api_with_retry(retries: int = 3, delay: float = 1.0) -> bool: last_err: Optional[Exception] = None for attempt in range(1, retries + 1): try: await generate_or_edit_and_save_image_async( prompt=inpaint_prompt, save_path=clean_bg_path, aspect_ratio="16:9", api_url=api_url, api_key=api_key, model=model, image_path=temp_img_path, use_edit=True, ) return True except Exception as e: last_err = e log.error(f"[convert_images_dir_to_pdf_and_ppt_api] slide#{idx} inpainting attempt {attempt}/{retries} failed: {e}") if attempt < retries: try: await asyncio.sleep(delay) except Exception: pass log.error(f"[convert_images_dir_to_pdf_and_ppt_api] slide#{idx} inpainting failed after {retries} attempts: {last_err}") return False try: # 生成文字掩码 text_mask = build_adaptive_mask(bgr, lines) # 保存临时文件 import tempfile with tempfile.TemporaryDirectory() as tmpdir: temp_img_path = os.path.join(tmpdir, f"temp_{idx}.png") clean_bg_path = os.path.join(tmpdir, f"clean_{idx}.png") cv2.imwrite(temp_img_path, bgr) # 构造 inpainting 提示词 inpaint_prompt = "请智能修复图像中文字被移除后的区域,保持背景的连续性、一致性和自然过渡,使修复后的图像看起来完整无缺,并且原图涉及的图标你需要尽量保留;" log.info(f"[convert_images_dir_to_pdf_and_ppt_api] slide#{idx} 开始调用图像编辑API进行inpainting(最多重试3次)...") api_success = await _call_inpaint_api_with_retry(retries=3, delay=1.0) if api_success and os.path.exists(clean_bg_path): bg_for_slide = read_bgr(clean_bg_path) log.info(f"[convert_images_dir_to_pdf_and_ppt_api] slide#{idx} API inpainting成功") else: log.warning(f"[convert_images_dir_to_pdf_and_ppt_api] slide#{idx} API inpainting失败,使用本地inpaint") bg_for_slide = make_clean_background(bgr, lines) except Exception as e: log.error(f"[convert_images_dir_to_pdf_and_ppt_api] slide#{idx} inpainting流程失败: {e},使用本地inpaint") try: bg_for_slide = make_clean_background(bgr, lines) except Exception as e2: log.error(f"[convert_images_dir_to_pdf_and_ppt_api] slide#{idx} 本地inpaint也失败: {e2},使用原图") bg_for_slide = bgr elif clean_background and lines: # 不使用 API,直接使用本地 inpaint log.info(f"slide#{idx} applying local inpainting...") bg_for_slide = make_clean_background(bgr, lines) tmp = f"__ppt_bg_{idx}.png" add_background(slide, bg_for_slide, slide_w_emu, slide_h_emu, tmp) # 添加文本框(与原函数相同的逻辑) scale_x = slide_w_emu / w0 scale_y = slide_h_emu / h0 for bbox, text, conf in lines: x1, y1, x2, y2 = bbox if (x2 - x1) < 6 or (y2 - y1) < 6: continue font_size = estimate_font_pt(bbox, img_h_px=h0, body_h_px=body_h_px) bbox_width_emu = px_to_emu((x2 - x1), scale_x) bbox_height_emu = px_to_emu((y2 - y1), scale_y) width = bbox_width_emu height = bbox_height_emu left = px_to_emu(x1, scale_x) top = px_to_emu(y1, scale_y) tb = slide.shapes.add_textbox(left, top, int(width), int(height)) tf = tb.text_frame tf.clear() tf.word_wrap = False tb.fill.background() tb.line.fill.background() # 垂直居中(可选) tf.vertical_anchor = MSO_ANCHOR.MIDDLE p = tf.paragraphs[0] p.text = text # 使用原生分散对齐 if len(text) > 1: p.alignment = PP_ALIGN.DISTRIBUTE else: p.alignment = PP_ALIGN.CENTER p.font.size = font_size p.font.spacing = Pt(0) if use_text_color: text_color = extract_text_color(bgr, bbox, bg_color) p.font.color.rgb = RGBColor(*text_color) else: p.font.color.rgb = RGBColor(0, 0, 0) prs.save(output_pptx_path) result["pptx"] = output_pptx_path return result if __name__ == "__main__": """ 简单本地测试入口: - 直接运行本文件即可测试 PaddleOCR 对指定图片的识别效果 - 识别结果会打印在终端,并把画好检测框的图片保存到指定路径 """ # 测试图片路径(也是可视化输出路径) img_path = f"{get_project_root()}/tests/test_02.png" if not os.path.exists(img_path): raise FileNotFoundError(f"测试图片不存在: {img_path}") # 调用封装好的单页接口 info = paddle_ocr_page_with_layout(img_path) print("=== PaddleOCR 测试结果 ===") print(f"image_size: {info['image_size']}") print(f"body_h_px: {info['body_h_px']}") print(f"bg_color: {info['bg_color']}") print(f"检测到文本框数量: {len(info['lines'])}") for i, (bbox, text, conf) in enumerate(info["lines"], start=1): print(f"[{i:02d}] conf={conf:.1f} bbox={bbox} text={text}") # 把检测框画在图上,并保存到文件,而不是弹出窗口 try: bgr = read_bgr(img_path) vis = bgr.copy() for bbox, text, conf in info["lines"]: x1, y1, x2, y2 = map(int, bbox) cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 2) save_path = f"{get_project_root()}/tests/test_01_paddle_frame.png" ok = cv2.imwrite(save_path, vis) if ok: log.info(f"PaddleOCR 可视化结果已保存到: {save_path}") else: log.warning(f"PaddleOCR 可视化结果保存失败: {save_path}") except Exception as e: log.warning(f"可视化失败: {e}") # 不影响纯文本打印结果