| |
| """ |
| 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 文稿的后处理工具 |
| - 也可在其它组件或脚本中通过对外函数直接调用 |
| """ |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import os |
| import re |
| from typing import Sequence, Optional, Dict, Any, List, Tuple |
| import requests |
| import random |
| from collections import Counter |
|
|
| import fitz |
| from pathlib import Path |
|
|
| import numpy as np |
| from PIL import Image |
| import cv2 |
| try: |
| from paddleocr import PaddleOCR |
| except Exception: |
| PaddleOCR = None |
|
|
| 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__) |
|
|
| |
| |
| |
| ADD_BACKGROUND_IMAGE = True |
| CLEAN_BACKGROUND = True |
| EXTRACT_TEXT_COLOR = True |
| INPAINT_METHOD = cv2.INPAINT_TELEA |
| INPAINT_RADIUS = 7 |
| SIMPLE_BG_VAR_THRESH = 50.0 |
| MASK_DILATE_ITER = 2 |
| USE_ADAPTIVE_MASK = True |
|
|
| |
| SLIDE_W_IN = 13.333 |
| SLIDE_H_IN = 7.5 |
|
|
| |
| DEBUG_DUMP_FIRST_N = 2 |
| DEBUG_DIR = f"{get_project_root()}/tests/debug_frames" |
|
|
| |
| UPSCALE_LONG_SIDE_TO = 2200 |
| UPSCALE_INTERP = cv2.INTER_CUBIC |
| ENABLE_SHARPEN = True |
| SHARPEN_AMOUNT = 0.8 |
|
|
| |
| DROP_SCORE = 30 |
|
|
| |
| 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 |
|
|
| |
| |
| if PaddleOCR is not None: |
| PADDLE_OCR = PaddleOCR( |
| use_angle_cls=True, |
| lang="ch", |
| ) |
| else: |
| PADDLE_OCR = None |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| if len(data) < self.n_clusters: |
| self.centroids = sorted(list(set(data))) |
| return self |
|
|
| |
| if not self.has_sklearn: |
| |
| |
| 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 |
|
|
| |
| import numpy as np |
| X = np.array(data).reshape(-1, 1) |
| |
| |
| 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()) |
|
|
| |
| 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) |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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}") |
|
|
| |
| 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}") |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|
|
|
| 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 = PADDLE_OCR.ocr(bgr, cls=True) |
| lines = [] |
|
|
| if not ocr_result: |
| return lines |
|
|
| |
| for line in ocr_result[0]: |
| box, (text, score) = line |
| if not text: |
| continue |
| if score * 100.0 < drop_score: |
| continue |
|
|
| |
| 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)] |
| |
| 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}") |
|
|
| |
| 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() |
| |
| |
| |
| 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}") |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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: |
| |
| 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 = 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 |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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) |
| |
| |
| |
| slide_h_pt = slide_h_in * 72.0 |
| |
| |
| height_ratio = h_px / float(img_h_px) |
| |
| |
| 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: |
| |
| 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 |
|
|
| |
| if USE_ADAPTIVE_MASK: |
| main_mask = build_adaptive_mask(bgr, lines) |
| else: |
| main_mask = build_text_mask_from_lines(bgr, lines) |
|
|
| |
| 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: |
| |
| clean = cv2.inpaint(bgr, shadow_mask, INPAINT_RADIUS, cv2.INPAINT_TELEA) |
| else: |
| |
| 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) |
|
|
| |
| 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] |
|
|
| |
| slide = prs.slides.add_slide(prs.slide_layouts[6]) |
|
|
| |
| 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) |
|
|
| 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}") |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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} |
| |
| |
| if output_pdf_path is not None: |
| result["pdf"] = images_to_pdf(image_paths, output_pdf_path) |
| |
| |
| 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]) |
| |
| |
| 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 |
| |
| |
| 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: |
| |
| 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) |
| |
| |
| 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: |
| |
| 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}") |
| |
|
|