from __future__ import annotations import math from pathlib import Path from PIL import Image, ImageDraw, ImageFont from docx import Document from docx.enum.section import WD_SECTION from docx.enum.table import WD_ALIGN_VERTICAL, WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Inches, Pt, RGBColor ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "系统部署性能分析评估报告.docx" ASSET_DIR = ROOT / "report_assets" CHART = ASSET_DIR / "capacity_requirement.png" FONT_REGULAR = Path(r"C:\Windows\Fonts\msyh.ttc") FONT_BOLD = Path(r"C:\Windows\Fonts\msyhbd.ttc") INK = "1F2937" MUTED = "5B6472" BLUE = "1F4E78" BLUE_LIGHT = "DCE6F1" PALE_BLUE = "F3F7FB" GOLD = "C58A19" PALE_GOLD = "FFF5D6" RED = "A33A3A" PALE_RED = "FBEAEA" GREEN = "2F6B57" PALE_GREEN = "E8F2ED" GRID = "D9D9D9" WHITE = "FFFFFF" def font(size: int, bold: bool = False): path = FONT_BOLD if bold else FONT_REGULAR return ImageFont.truetype(str(path), size=size) def make_capacity_chart(path: Path): """Static report chart contract. Question: What aggregate single-model throughput is required for 50 cameras? Takeaway: Requirement scales from 50 infer/s at 1 fps to 250 infer/s at 5 fps; the measured serial PyTorch reference is below even the 1 fps workload. Family: comparison, horizontal bar. Palette: one blue root plus a gold reference. """ path.parent.mkdir(parents=True, exist_ok=True) width, height = 1800, 980 img = Image.new("RGB", (width, height), "#FFFFFF") d = ImageDraw.Draw(img) title_f = font(48, True) sub_f = font(27, False) label_f = font(29, False) value_f = font(30, True) axis_f = font(23, False) note_f = font(23, False) d.text((90, 55), "五十路单模型推理吞吐需求", fill="#111827", font=title_f) d.text((90, 125), "单位 推理次数每秒 公式 摄像头数乘每路分析帧率", fill="#5B6472", font=sub_f) labels = [ "五十路各一帧每秒", "四十路一帧加十路五帧", "五十路各两帧每秒", "五十路各五帧每秒", ] values = [50, 90, 100, 250] x0, x1 = 620, 1650 y0 = 250 row_h = 135 max_v = 260 for tick in [0, 50, 100, 150, 200, 250]: x = x0 + (x1 - x0) * tick / max_v d.line((x, y0 - 30, x, y0 + row_h * len(values) - 25), fill="#E5E7EB", width=2) txt = str(tick) bb = d.textbbox((0, 0), txt, font=axis_f) d.text((x - (bb[2] - bb[0]) / 2, y0 - 70), txt, fill="#6B7280", font=axis_f) for i, (lab, val) in enumerate(zip(labels, values)): y = y0 + i * row_h d.text((90, y + 20), lab, fill="#1F2937", font=label_f) bar_end = x0 + (x1 - x0) * val / max_v d.rounded_rectangle((x0, y + 10, bar_end, y + 72), radius=14, fill="#2F6EA3", outline="#1F4E78", width=2) d.text((bar_end + 18, y + 20), str(val), fill="#1F2937", font=value_f) ref = 20.4 rx = x0 + (x1 - x0) * ref / max_v d.line((rx, y0 - 25, rx, y0 + row_h * len(values) - 20), fill="#C58A19", width=5) d.text((rx + 12, y0 + row_h * len(values) - 8), "当前开发机串行参考 20.4", fill="#8A5E12", font=note_f) d.text((90, 850), "说明 参考值来自 RTX 5060 Laptop GPU 上 YOLO26x 640 PyTorch FP16 的二十次本地单图测试", fill="#5B6472", font=note_f) d.text((90, 892), "不含 RTSP 解码 进程间传输 JPEG 编解码和告警处理 因此只能视为吞吐上限参考", fill="#5B6472", font=note_f) img.save(path, quality=95) def set_cell_shading(cell, fill: str): tc_pr = cell._tc.get_or_add_tcPr() shd = tc_pr.find(qn("w:shd")) if shd is None: shd = OxmlElement("w:shd") tc_pr.append(shd) shd.set(qn("w:fill"), fill) def set_cell_margins(cell, top=110, start=120, bottom=110, end=120): tc = cell._tc tc_pr = tc.get_or_add_tcPr() tc_mar = tc_pr.first_child_found_in("w:tcMar") if tc_mar is None: tc_mar = OxmlElement("w:tcMar") tc_pr.append(tc_mar) for m, v in (("top", top), ("start", start), ("bottom", bottom), ("end", end)): node = tc_mar.find(qn(f"w:{m}")) if node is None: node = OxmlElement(f"w:{m}") tc_mar.append(node) node.set(qn("w:w"), str(v)) node.set(qn("w:type"), "dxa") def set_table_borders(table, color=GRID, size="6"): tbl_pr = table._tbl.tblPr borders = tbl_pr.first_child_found_in("w:tblBorders") if borders is None: borders = OxmlElement("w:tblBorders") tbl_pr.append(borders) for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): tag = qn(f"w:{edge}") el = borders.find(tag) if el is None: el = OxmlElement(f"w:{edge}") borders.append(el) el.set(qn("w:val"), "single") el.set(qn("w:sz"), size) el.set(qn("w:space"), "0") el.set(qn("w:color"), color) def set_repeat_table_header(row): tr_pr = row._tr.get_or_add_trPr() tbl_header = OxmlElement("w:tblHeader") tbl_header.set(qn("w:val"), "true") tr_pr.append(tbl_header) def prevent_row_split(row): tr_pr = row._tr.get_or_add_trPr() cant = OxmlElement("w:cantSplit") tr_pr.append(cant) def set_cell_width(cell, width_cm: float): tc_pr = cell._tc.get_or_add_tcPr() tc_w = tc_pr.find(qn("w:tcW")) if tc_w is None: tc_w = OxmlElement("w:tcW") tc_pr.append(tc_w) tc_w.set(qn("w:w"), str(int(Cm(width_cm).emu / 635))) tc_w.set(qn("w:type"), "dxa") def set_run_font(run, name="Microsoft YaHei", size=None, bold=None, color=None): run.font.name = name rpr = run._element.get_or_add_rPr() rfonts = rpr.rFonts if rfonts is None: rfonts = OxmlElement("w:rFonts") rpr.append(rfonts) for attr in ("ascii", "hAnsi", "eastAsia", "cs"): rfonts.set(qn(f"w:{attr}"), name) if size is not None: run.font.size = Pt(size) if bold is not None: run.bold = bold if color is not None: run.font.color.rgb = RGBColor.from_string(color) def add_page_number(paragraph): paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER run = paragraph.add_run() fld_char1 = OxmlElement("w:fldChar") fld_char1.set(qn("w:fldCharType"), "begin") instr_text = OxmlElement("w:instrText") instr_text.set(qn("xml:space"), "preserve") instr_text.text = " PAGE " fld_char2 = OxmlElement("w:fldChar") fld_char2.set(qn("w:fldCharType"), "end") run._r.extend([fld_char1, instr_text, fld_char2]) set_run_font(run, size=9, color="7A7A7A") def add_hyperlink(paragraph, text: str, url: str): part = paragraph.part rel_id = part.relate_to(url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", is_external=True) hyperlink = OxmlElement("w:hyperlink") hyperlink.set(qn("r:id"), rel_id) new_run = OxmlElement("w:r") r_pr = OxmlElement("w:rPr") color = OxmlElement("w:color") color.set(qn("w:val"), "1F4E78") r_pr.append(color) underline = OxmlElement("w:u") underline.set(qn("w:val"), "single") r_pr.append(underline) r_fonts = OxmlElement("w:rFonts") for attr in ("ascii", "hAnsi", "eastAsia"): r_fonts.set(qn(f"w:{attr}"), "Microsoft YaHei") r_pr.append(r_fonts) new_run.append(r_pr) t = OxmlElement("w:t") t.text = text new_run.append(t) hyperlink.append(new_run) paragraph._p.append(hyperlink) def add_table(doc, headers, rows, widths=None, font_size=8.7, header_fill=BLUE): table = doc.add_table(rows=1, cols=len(headers)) table.alignment = WD_TABLE_ALIGNMENT.CENTER table.autofit = False set_table_borders(table) hdr = table.rows[0] set_repeat_table_header(hdr) prevent_row_split(hdr) for i, text in enumerate(headers): cell = hdr.cells[i] set_cell_shading(cell, header_fill) set_cell_margins(cell) cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER if widths: set_cell_width(cell, widths[i]) p = cell.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(0) r = p.add_run(str(text)) set_run_font(r, size=font_size, bold=True, color=WHITE) for ridx, row in enumerate(rows): cells = table.add_row().cells prevent_row_split(table.rows[-1]) for i, value in enumerate(row): cell = cells[i] set_cell_margins(cell) cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER if widths: set_cell_width(cell, widths[i]) if ridx % 2 == 1: set_cell_shading(cell, PALE_BLUE) p = cell.paragraphs[0] p.paragraph_format.space_after = Pt(0) p.paragraph_format.line_spacing = 1.05 p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 and len(str(value)) < 18 else WD_ALIGN_PARAGRAPH.LEFT r = p.add_run(str(value)) set_run_font(r, size=font_size, color=INK) doc.add_paragraph().paragraph_format.space_after = Pt(1) return table def add_caption(doc, text): p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.keep_with_next = True p.paragraph_format.space_before = Pt(2) p.paragraph_format.space_after = Pt(5) r = p.add_run(text) set_run_font(r, size=9, color=MUTED) return p def add_bullet(doc, text, level=0, lead=None): p = doc.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2") p.paragraph_format.space_after = Pt(3) p.paragraph_format.left_indent = Cm(0.65 + 0.55 * level) p.paragraph_format.first_line_indent = Cm(-0.35) if lead and text.startswith(lead): r1 = p.add_run(lead) set_run_font(r1, bold=True, size=10.2, color=INK) r2 = p.add_run(text[len(lead):]) set_run_font(r2, size=10.2, color=INK) else: r = p.add_run(text) set_run_font(r, size=10.2, color=INK) return p def add_numbered(doc, text): p = doc.add_paragraph(style="List Number") p.paragraph_format.space_after = Pt(3) r = p.add_run(text) set_run_font(r, size=10.2, color=INK) return p def add_manual_numbered(doc, number, text): """Add a visually numbered paragraph without inheriting Word list state.""" p = doc.add_paragraph() p.paragraph_format.space_after = Pt(3) p.paragraph_format.left_indent = Cm(0.65) p.paragraph_format.first_line_indent = Cm(-0.35) r1 = p.add_run(f"{number}. ") set_run_font(r1, size=10.2, bold=True, color=INK) r2 = p.add_run(text) set_run_font(r2, size=10.2, color=INK) return p def add_para(doc, text="", bold_lead=None, style=None, align=None): p = doc.add_paragraph(style=style) p.paragraph_format.space_after = Pt(6) p.paragraph_format.line_spacing = 1.28 if align is not None: p.alignment = align if bold_lead and text.startswith(bold_lead): a = p.add_run(bold_lead) set_run_font(a, size=10.5, bold=True, color=INK) b = p.add_run(text[len(bold_lead):]) set_run_font(b, size=10.5, color=INK) else: r = p.add_run(text) set_run_font(r, size=10.5, color=INK) return p def add_heading(doc, text, level=1): p = doc.add_paragraph(text, style=f"Heading {level}") p.paragraph_format.keep_with_next = True p.paragraph_format.keep_together = True return p def configure_styles(doc): styles = doc.styles normal = styles["Normal"] normal.font.name = "Microsoft YaHei" normal.font.size = Pt(10.5) normal.font.color.rgb = RGBColor.from_string(INK) normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") normal.paragraph_format.line_spacing = 1.28 normal.paragraph_format.space_after = Pt(6) title = styles["Title"] title.font.name = "Microsoft YaHei" title._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") title.font.size = Pt(27) title.font.bold = True title.font.color.rgb = RGBColor(0, 0, 0) title.paragraph_format.space_after = Pt(16) # Remove the built-in Title style's decorative bottom border so the cover # remains clean and does not render as an unintended horizontal rule. ppr = title._element.get_or_add_pPr() pbd = ppr.find(qn("w:pBdr")) if pbd is not None: ppr.remove(pbd) for name, size, before, after in (("Heading 1", 17, 18, 9), ("Heading 2", 13, 13, 6), ("Heading 3", 11, 9, 4)): st = styles[name] st.font.name = "Microsoft YaHei" st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") st.font.size = Pt(size) st.font.bold = True st.font.color.rgb = RGBColor(0, 0, 0) st.paragraph_format.space_before = Pt(before) st.paragraph_format.space_after = Pt(after) st.paragraph_format.keep_with_next = True for name in ("List Bullet", "List Bullet 2", "List Number"): st = styles[name] st.font.name = "Microsoft YaHei" st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") st.font.size = Pt(10.2) st.font.color.rgb = RGBColor.from_string(INK) def set_core_properties(doc): cp = doc.core_properties cp.title = "视频监控平台五十路摄像头系统部署性能分析评估报告" cp.subject = "区域检测 目标检测与告警场景部署容量和性能评估" cp.author = "视频监控平台项目组" cp.keywords = "视频监控, YOLO, 50路摄像头, GPU, 部署, 性能评估" cp.comments = "基于代码审查 本地参考测试和公开产品资料形成" def build_document(): make_capacity_chart(CHART) doc = Document() configure_styles(doc) set_core_properties(doc) sec = doc.sections[0] sec.page_width = Cm(21.0) sec.page_height = Cm(29.7) sec.top_margin = Cm(2.0) sec.bottom_margin = Cm(1.8) sec.left_margin = Cm(2.15) sec.right_margin = Cm(2.15) sec.header_distance = Cm(0.8) sec.footer_distance = Cm(0.8) add_page_number(sec.footer.paragraphs[0]) # Cover p = doc.add_paragraph() p.paragraph_format.space_before = Pt(70) p.alignment = WD_ALIGN_PARAGRAPH.CENTER r = p.add_run("视频监控平台五十路摄像头\n系统部署性能分析评估报告") set_run_font(r, size=27, bold=True, color="000000") p.style = doc.styles["Title"] p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(38) r = p.add_run("区域检测 目标检测与告警场景") set_run_font(r, size=15, color="000000") add_table( doc, ["评估对象", "评估基准", "报告日期"], [["视频监控平台版本 1.003", "五十路网络摄像头", "二零二六年九月四日"]], widths=[5.4, 5.4, 5.4], font_size=10, header_fill=BLUE, ) p = doc.add_paragraph() p.paragraph_format.space_before = Pt(26) p.paragraph_format.space_after = Pt(10) r = p.add_run("核心结论") set_run_font(r, size=13, bold=True, color="000000") add_para( doc, "当前系统可以完成多路接入和区域目标告警,但不宜在不改造的情况下直接承诺五十路持续目标检测。" "主要限制不是摄像头接入带宽,而是共享推理链路仍由单个同步转发线程串行处理,且每路摄像头独立使用 OpenCV 进行软件解码。" "若按启用布控的默认值每路每秒分析一帧,五十路需要至少五十次推理每秒;当前开发机参考测试仅达到二十点四次每秒,且该结果尚未计入 RTSP 解码和进程间传输开销。", ) add_para( doc, "建议将部署分为两个门槛。第一门槛先完成推理并发调度 硬件解码 子码流和异步告警落库改造;第二门槛在目标服务器上通过五十路七十二小时压力与故障恢复验收。" "生产硬件建议从二十四至三十二个物理核心 一百二十八 GB ECC 内存 双 NVIDIA L4 二十四 GB 或经实测等效的单张高端专业 GPU 双十 GbE 和独立 NVMe 系统盘起步。" "录像容量必须按现场码率另行配置,五十路四 Mbps 保存七天约需十八点一 TB 可用空间,已含百分之二十余量。", ) doc.add_page_break() add_heading(doc, "目录", 1) toc_items = [ "一 评估结论和部署判定", "二 评估范围和容量假设", "三 现有系统架构和代码证据", "四 五十路负载容量模型", "五 操作系统和软件环境要求", "六 硬件配置建议", "七 主要性能风险和解决方案", "八 推荐部署架构", "九 现场测试和验收方案", "十 实施顺序和采购门槛", "十一 限制条件和待确认问题", "附录 计算方法 测试记录和参考资料", ] for item in toc_items: p = doc.add_paragraph() p.paragraph_format.space_after = Pt(4) r = p.add_run(item) set_run_font(r, size=10.5, color=INK) add_heading(doc, "一 评估结论和部署判定", 1) add_heading(doc, "现状判定", 2) add_para( doc, "现状结论 平台具备五十路视频接入的功能基础,但当前代码路径尚不具备可证明的五十路持续目标检测生产容量。" "ZLMediaKit 只做代理和协议转换时,五十路二百至三百 Mbps 的典型入口流量不会成为首要瓶颈;真正的瓶颈位于取帧解码 共享推理调度 以及告警写入。", bold_lead="现状结论 ", ) add_para( doc, "当前区域入侵 越线 密度 滞留和方向规则会设置持续检测标志,使目标检测不再受运动门控保护。" "所以一旦五十路都启用这些规则,系统必须稳定承担所有路的设定分析帧率,不能用现场静止画面比例来乐观折算。", ) add_heading(doc, "建议判定", 2) add_table( doc, ["场景", "当前版本直接部署", "完成关键改造后", "判定"], [ ["五十路接入和网页预览", "可进行现场验证", "可", "低风险"], ["五十路区域目标检测 每路一帧每秒", "不建议承诺", "可作为生产目标", "需改造和压测"], ["十路五帧加四十路一帧每秒", "不可证明", "推荐的分级分析目标", "需批处理和硬件解码"], ["五十路各五帧每秒", "不可行", "需要多 GPU 和更小模型", "高成本专项方案"], ["五十路全天录像七天", "功能可用但未做规模验收", "可", "必须独立存储和故障测试"], ], widths=[4.0, 4.2, 4.4, 3.6], ) add_heading(doc, "采购前必须满足的三个条件", 2) add_numbered(doc, "先修复单线程同步推理转发,使多个 GPU worker 能真正并发,并加入有界最新帧队列和公平调度。") add_numbered(doc, "在实际目标模型 实际摄像头子码流和目标服务器上测得持续吞吐,生产目标至少保留百分之四十容量余量。") add_numbered(doc, "完成五十路七十二小时稳定性 压力 告警风暴 断流重连和磁盘写满保护测试后再签署容量结论。") add_heading(doc, "二 评估范围和容量假设", 1) add_heading(doc, "业务范围", 2) add_para( doc, "本报告覆盖五十路 RTSP 或 GB28181 摄像头接入 ZLMediaKit 转发 OpenCV 取帧 YOLO 目标检测 多边形区域和越线等后处理 告警截图和数据库记录,以及可选的全天录像。" "不包括大规模人脸库检索 跨摄像头 ReID 的最终生产容量 云端大模型费用和带宽,或外部告警平台的接口容量。", ) add_heading(doc, "基准假设", 2) add_table( doc, ["参数", "基准值", "敏感范围", "说明"], [ ["摄像头数量", "50 路", "固定", "全部可同时在线"], ["主码流", "1080p H264 或 H265", "2 至 8 Mbps", "用于预览和录像"], ["分析子码流", "640p 至 960p", "10 至 15 fps", "建议单独启用 分析输入仍缩放至 640"], ["目标检测模型", "一个 YOLO26x 640", "YOLO26s m x", "当前数据库默认模型文件约 118.7 MB"], ["区域规则分析频率", "每路 1 fps", "0.25 至 5 fps", "Zone 默认一秒一帧"], ["告警重复间隔", "30 秒", "按规则调整", "持续入侵时按目标和规则重报"], ["录像保存", "7 天", "0 至 30 天", "默认配置已启用录像服务 每路还需 record_enable"], ["可用性目标", "单机生产", "建议双机热备或冷备", "当前平台仍是单节点架构"], ], widths=[3.4, 4.1, 3.8, 5.9], ) add_para( doc, "公式 总推理需求等于摄像头数乘每路分析帧率乘每帧模型数乘检测占空比。" "对于区域 越线 密度 滞留和方向规则,当前实现会强制持续检测,检测占空比按一计算。" "同一路绑定两个不同小模型时,总推理次数近似翻倍。", bold_lead="公式 ", ) add_heading(doc, "三 现有系统架构和代码证据", 1) add_heading(doc, "视频和分析处理链路", 2) add_table( doc, ["处理阶段", "当前实现", "扩展到五十路的含义"], [ ["视频接入", "ZLMediaKit 代理 RTSP RTMP GB28181", "直通转发不转码时 CPU 压力相对可控"], ["摄像头执行单元", "每路一个独立分析子进程和一个解码线程", "约五十个 Python 子进程及大量队列和句柄"], ["取帧", "OpenCV VideoCapture 通过 FFmpeg 抓帧", "未显式启用 NVDEC 或 QSV 五十路解码主要压在 CPU"], ["帧队列", "每路 deque 最大长度二 取最新帧并丢弃旧帧", "能限制延迟和内存 但丢帧数量会持续增长"], ["检测触发", "运动门控或规则强制持续检测", "常用区域规则使运动门控失去降载作用"], ["推理 IPC", "子进程 JPEG 质量九十五编码后进入共享队列", "增加 CPU 编码 内存拷贝和延迟"], ["共享推理", "主进程单转发线程同步等待推理池返回", "同一时刻仅一个请求在途 多 worker 无法充分并行"], ["告警", "子进程同步保存 JPEG 主进程单事件桥写 SQLite", "告警风暴下磁盘和数据库会形成串行背压"], ["录像", "每路一个 FFmpeg 进程 码流复制后十分钟分段", "五十进程可接受但需要独立磁盘和句柄监控"], ], widths=[3.0, 6.2, 7.0], ) add_heading(doc, "对容量影响最大的代码事实", 2) add_bullet(doc, "共享推理入口队列最大一百二十八 推理池请求和响应队列各六十四 每路响应队列最大一百二十八。") add_bullet(doc, "主进程 inference forwarder 逐个调用 detect jpeg 并同步等待最多三十秒 因而当前吞吐上限由单请求完整耗时决定。") add_bullet(doc, "配置虽然允许一至三十二个 inference worker 但当前串行转发无法持续提交多个并行任务。") add_bullet(doc, "布控分析频率取同一路所有启用区域中的最高值 不会对不同区域分别调度。") add_bullet(doc, "每个分析子进程先把原帧 JPEG 编码 推理 worker 再解码 多路高频时会产生明显 CPU 和内存带宽开销。") add_bullet(doc, "SQLite 使用 DELETE journal 和单线程告警写入 已关闭每帧轨迹写库 这是正确优化 但仍不适合高并发告警和多进程查询混合负载。") add_bullet(doc, "告警截图每路每分钟最多六十张 但未看到报警截图按天数或容量自动清理策略 极端上限达到三千张每分钟。") add_bullet(doc, "服务重启后需要人工重新启动分析 不满足无人值守现场的自动恢复要求。") add_heading(doc, "已有的正向设计", 2) add_para( doc, "现有实现并非完全按帧堆积。每路帧队列只保留最新两帧,分析循环取最新帧后清空中间帧;模型在共享 worker 内按算法配置缓存;每帧轨迹快照写库已经停用;录像采用码流复制而不是重新编码。" "这些设计有利于控制延迟 显存重复占用和数据库写入,应该在后续重构中保留。", ) add_heading(doc, "四 五十路负载容量模型", 1) add_heading(doc, "推理吞吐需求", 2) add_caption(doc, "图一 五十路在不同分析策略下的单模型吞吐需求") p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER r = p.add_run() r.add_picture(str(CHART), width=Cm(16.2)) # Provide a meaningful description for screen readers and document QA. inline = r._element.xpath('.//wp:inline') if inline: doc_pr = inline[0].find(qn('wp:docPr')) if doc_pr is not None: doc_pr.set('descr', '柱状图:五十路在零点二五、一、混合九十、二和五帧每秒策略下的单模型推理需求,并标出当前开发机二十点四次每秒参考线。') doc_pr.set('title', '五十路单模型推理吞吐需求') p.paragraph_format.space_after = Pt(6) add_para( doc, "图中二十点四次每秒是本项目开发机上的参考测试,不是目标服务器承诺值。" "测试使用 RTX 5060 Laptop GPU 八 GB YOLO26x 640 PyTorch FP16,三次预热后二十次推理平均四十九点一四 ms,P95 为五十三点一五 ms。" "测试只使用内存中的零图像,没有包含 RTSP 解码 JPEG 进程间传输 业务规则 告警截图和多路竞争,因此现场完整链路只会更慢。", ) add_table( doc, ["分析策略", "总推理需求", "建议验收吞吐", "适用规则"], [ ["五十路各 0.25 fps", "12.5 次每秒", "至少 20 次每秒", "低变化区域巡检"], ["五十路各 1 fps", "50 次每秒", "至少 75 次每秒", "区域入侵和滞留基础方案"], ["四十路 1 fps 加十路 5 fps", "90 次每秒", "至少 135 次每秒", "普通区域加关键越线方向"], ["五十路各 2 fps", "100 次每秒", "至少 150 次每秒", "更快目标或更短告警延迟"], ["五十路各 5 fps", "250 次每秒", "至少 375 次每秒", "全路高频跟踪 需多 GPU"], ], widths=[5.2, 3.2, 3.6, 4.2], ) add_para( doc, "验收吞吐按目标需求除以零点六七计算,约保留百分之五十峰值余量。" "如果同一路要顺序运行两个模型,上表总需求和验收吞吐都应乘二。" "在硬件采购时应以端到端吞吐和 P95 延迟为约束,不使用 CUDA 核心数或理论 TOPS 直接换算路数。", ) add_heading(doc, "网络容量", 2) add_table( doc, ["单路平均码率", "五十路入口流量", "含百分之三十余量", "七天录像原始容量", "含百分之二十余量"], [ ["2 Mbps", "100 Mbps", "130 Mbps", "7.56 TB", "9.07 TB"], ["4 Mbps", "200 Mbps", "260 Mbps", "15.12 TB", "18.14 TB"], ["6 Mbps", "300 Mbps", "390 Mbps", "22.68 TB", "27.22 TB"], ["8 Mbps", "400 Mbps", "520 Mbps", "30.24 TB", "36.29 TB"], ], widths=[3.2, 3.2, 3.4, 3.6, 3.8], ) add_para( doc, "一 GbE 从纯带宽看可承载五十路典型码流,但管理访问 录像写入回放和故障重传会占用余量。" "生产建议双十 GbE 分别连接摄像头网和管理存储网 即使现场交换机暂时只有千兆也为后续扩展和链路隔离保留空间。", ) add_heading(doc, "当前版本容量边界", 2) add_para( doc, "在不改造单线程同步推理转发和软件解码的前提下,当前开发机实测吞吐不足以覆盖五十路每路一帧每秒的基础需求。" "因此采购与项目承诺应以改造后的目标服务器实测结果为准,并至少保留百分之四十持续吞吐余量;本节容量表只用于网络和存储初算,不构成整机路数承诺。", ) add_heading(doc, "内存和进程规模", 2) add_para( doc, "一帧 1080p BGR 图像约六 MB。每路保留帧队列 最近帧和处理副本时,仅像素数据即可达到约十八至三十 MB;再叠加 Python OpenCV 背景模型 JPEG 缓冲 队列和解释器,五十个分析进程通常需要数十 GB 内存。" "因此六十四 GB 只能作为功能验证下限,生产建议一百二十八 GB ECC,并将常驻内存百分之八十设为告警阈值。", ) add_heading(doc, "五 操作系统和软件环境要求", 1) add_heading(doc, "不改代码的近期部署路径", 2) add_para( doc, "若以当前安装包和现有运行方式为准,建议使用 Windows 11 Pro 或 Enterprise 64 位。" "项目部署说明明确覆盖 Windows 10 和 Windows 11 x64,GPU 依赖锁定为 PyTorch 2.8 CUDA 12.9。" "现场必须固定经过验证的 NVIDIA 驱动版本 关闭自动睡眠和休眠 采用专用服务账号,并将 ProgramData 中的数据库 配置 密钥 模型和录像纳入备份。" "不建议未经验证直接使用 Windows Server,因为当前文档未声明其安装和托盘服务路径已通过验证。", ) add_heading(doc, "完成服务化后的生产推荐路径", 2) add_para( doc, "完成容器化或 systemd 服务化以及 ZLMediaKit 重新编译后,建议使用 Ubuntu Server 24.04 LTS x86 64。" "Ubuntu 24.04 的标准安全维护到二零二九年,适合长期现场运行。" "仓库当前 Linux 二进制依赖 OpenSSL 1.1,并在说明中给出了安装 Ubuntu 20.04 libssl1.1 包的兼容方法;该方法不宜作为新的二零二六年生产基线。" "应在目标 LTS 上重新构建 ZLMediaKit 并验证 FFmpeg OpenCV NVIDIA 驱动 CUDA TensorRT 和 Python 依赖。", ) add_table( doc, ["层次", "要求", "现场控制点"], [ ["操作系统", "Windows 11 x64 现状路径 或 Ubuntu Server 24.04 LTS 改造路径", "统一补丁窗口 禁止日常办公软件共用"], ["GPU 驱动", "与 CUDA 12.9 或目标 TensorRT 版本匹配", "锁定版本并保留离线安装包"], ["Python", "3.10 以上 当前 Windows 包为 Python 3.12 依赖", "使用项目私有环境 禁止在线漂移升级"], ["推理运行时", "PyTorch 仅作兼容基线 生产优先 TensorRT FP16 或 INT8", "模型导出后做精度回归"], ["数据库", "验证期可 SQLite 生产建议 PostgreSQL", "独立备份 WAL 和连接池监控"], ["反向代理", "HTTPS Nginx 或等效组件", "管理面不暴露公网 IP 白名单"], ["时间同步", "NTP 统一时钟", "摄像头 服务器 存储保持一致"], ["服务管理", "开机自启 自动恢复 健康检查", "分析服务不再依赖人工点击启动"], ], widths=[3.1, 7.0, 6.1], ) add_heading(doc, "六 硬件配置建议", 1) add_heading(doc, "配置分级", 2) add_table( doc, ["部件", "功能验证配置", "五十路生产推荐配置", "高频或双模型配置"], [ ["适用负载", "五十路接入 总分析不超过 20 次每秒 不录或少量录像", "五十路 混合 90 次每秒目标 全量告警", "五十路 250 次每秒或双模型"], ["CPU", "16 物理核心 32 线程 高频 x86 64", "24 至 32 物理核心 48 至 64 线程 服务器级", "32 至 48 物理核心 双路或高端单路"], ["内存", "64 GB", "128 GB ECC", "128 至 256 GB ECC"], ["GPU", "16 GB 以上 CUDA GPU 以实测为准", "双 NVIDIA L4 24 GB 或单张 48 至 96 GB 专业 GPU 经实测达标", "双高端 48 GB 以上或四张 L4 按通道分片"], ["系统盘", "1 TB NVMe", "2 块 1.92 TB 企业 NVMe RAID1", "2 块 3.84 TB 企业 NVMe RAID1"], ["告警和数据库盘", "与系统盘共用 仅验证", "独立 1.92 至 3.84 TB 企业 NVMe", "独立 NVMe RAID1"], ["录像盘", "按需", "六块 8 TB 企业盘 RAID6 名义可用 32 TB", "八块 8 TB 以上 RAID6 或独立存储阵列"], ["网络", "双 1 GbE", "双 10 GbE 摄像头网和管理存储网隔离", "双 10 或 25 GbE"], ["电源散热", "按 GPU 额定功耗留余量", "冗余电源 机架风道 UPS", "按多 GPU 峰值功耗设计 建议 3 kVA 以上 UPS"], ], widths=[2.5, 4.5, 5.1, 4.1], font_size=8.3, ) add_para( doc, "GPU 选择说明 NVIDIA L4 提供二十四 GB 显存 四个 NVDEC 和七十二 W TDP,适合机架式边缘视频推理。" "RTX PRO 6000 Blackwell 提供九十六 GB ECC 显存,适合单卡需要较大模型余量的服务器或工作站,但功耗为四百至六百 W。" "两类设备都不能仅凭规格表确认本平台路数,必须用目标模型和完整链路压测。", bold_lead="GPU 选择说明 ", ) add_heading(doc, "推荐的五十路采购基线", 2) add_para( doc, "在完成软件并发改造并采用分析子码流的前提下,建议采购基线为单路二十四至三十二物理核心 CPU 一百二十八 GB ECC 内存 双 L4 二十四 GB 双一 TB 企业 NVMe RAID1 另加独立告警数据库 NVMe 双十 GbE 和六块八 TB RAID6 录像盘。" "双 GPU 不仅用于吞吐,还可将通道固定分片为二十五加二十五,单卡故障时保留降级分析能力。" "若机柜不支持双卡或被动散热,则选择单张高端专业 GPU,但仍建议保留一台可接管的备用服务器。", ) add_heading(doc, "七 主要性能风险和解决方案", 1) add_table( doc, ["优先级", "风险", "现场表现", "根因", "解决方案"], [ ["P0", "共享推理串行", "分析 FPS 明显低于目标 延迟和超时持续上升", "单转发线程同步等待每个请求", "改为异步多请求调度 按 GPU 分片 批量 1 4 8 动态组批"], ["P0", "CPU 解码饱和", "CPU 高 GPU 低 断流和画面卡顿", "五十路 OpenCV FFmpeg 软件解码", "使用子码流 NVDEC QSV 或 GStreamer 硬件解码 解码与推理解耦"], ["P0", "分析服务不能无人恢复", "重启后无告警 需人工点击", "分析启动状态未持久化自动恢复", "服务管理器开机恢复已启用通道 加 watchdog 和退避重连"], ["P1", "无批处理", "GPU 利用率波动 单帧开销高", "每帧单独 JPEG 和单张推理", "TensorRT FP16 或 INT8 动态 batch 同尺寸输入 限制组批等待"], ["P1", "JPEG IPC 成本", "多核 CPU 和内存带宽上升", "每路质量九十五编码 worker 再解码", "共享内存环形缓冲 传 NV12 或预处理张量 避免重复压缩"], ["P1", "SQLite 写锁", "报警列表卡顿 database locked", "DELETE journal 单写者和同步 ORM", "异步告警队列 PostgreSQL 批量写 分离查询和归档"], ["P1", "截图磁盘无界增长", "磁盘写满导致服务异常", "每路每分钟最多六十张但没有全局保留策略", "按天数和容量双阈值清理 磁盘水位保护 缩略图与原图分层"], ["P1", "大模型同步阻塞", "单路分析停顿数秒至三十秒", "LLM 调用在相机处理循环内同步执行", "异步复核队列 超时熔断 并发上限 不阻塞小模型告警"], ["P1", "多模型顺序执行", "同一路绑定模型后吞吐近似倍增", "pipeline 对 detector 逐个 detect", "合并类别 模型蒸馏 按 GPU 分组 必要时并行"], ["P2", "录像进程和磁盘扫描", "句柄多 每小时扫描阻塞机械盘", "每路 FFmpeg 进程和全目录 os walk", "录像独立服务 增量索引 分层目录 低峰清理"], ["P2", "监控指标不足", "故障只能看到平均 FPS", "缺少队列深度 P95 延迟和单路公平性", "增加 Prometheus 指标和容量告警 保存每路时序"], ], widths=[1.3, 2.7, 3.9, 4.0, 4.3], font_size=7.7, ) add_heading(doc, "推理并发改造要点", 2) add_manual_numbered(doc, 1, "把当前单个 inference forwarder 改为非阻塞请求路由,使每个 GPU worker 至少始终有一个请求在途。") add_manual_numbered(doc, 2, "按 stream id 做一致性分片,确保同一路结果顺序稳定;每路只保留最新待分析帧,超过时直接覆盖而不是排队。") add_manual_numbered(doc, 3, "对相同模型和输入尺寸使用一到八张的小批次,组批等待建议从五到十五 ms 起测,防止吞吐提升换来不可接受的告警延迟。") add_manual_numbered(doc, 4, "导出 TensorRT FP16 后做目标类别召回率和框偏移回归;INT8 只有在现场校准集验证通过后启用。") add_manual_numbered(doc, 5, "将每张 GPU 的模型实例数固定,避免每个 worker 重复加载全部模型造成显存浪费。") add_heading(doc, "分析频率分级", 2) add_table( doc, ["规则类型", "建议分析频率", "原因"], [ ["区域入侵和滞留", "0.5 至 1 fps", "目标停留时间长 重点保证召回和稳定"], ["密度", "1 至 2 fps", "人群变化通常慢于逐帧速率"], ["越线和方向", "3 至 5 fps", "轨迹跨线需要相邻观测 低帧率容易直接跳过线段"], ["关键高速通道", "5 至 10 fps", "车辆或快速人员 需要专项模型和多 GPU"], ["仅运动告警", "按运动门控", "可显著降载 但不能替代目标区域规则"], ], widths=[4.4, 4.0, 7.8], ) add_heading(doc, "八 推荐部署架构", 1) add_heading(doc, "单机生产架构", 2) add_table( doc, ["输入层", "媒体层", "分析层", "业务层", "数据层"], [[ "摄像头主码流用于预览和录像\n分析子码流用于算法", "ZLMediaKit 直通转发\n摄像头网独立 VLAN", "硬件解码池\n最新帧调度\n双 GPU 批推理", "跟踪和区域规则\n异步告警和 LLM 复核", "PostgreSQL\n告警 NVMe\n录像 RAID6", ]], widths=[3.3, 3.3, 3.4, 3.4, 3.2], font_size=8.5, ) add_para( doc, "主码流和分析子码流必须解耦。录像继续复制主码流,不因算法降分辨率而降低取证质量;分析层只拉低分辨率子码流,减少软件解码和 JPEG 传输成本。" "每张 GPU 固定处理二十五路,并允许故障时把非关键通道降到零点二五 fps,将关键通道转移到剩余 GPU。", ) add_heading(doc, "可靠性设计", 2) add_bullet(doc, "服务器和摄像头网络使用静态地址 VLAN 和访问控制 管理面通过 HTTPS 反向代理访问。") add_bullet(doc, "配置 模型 数据库 密钥和授权作为一组备份 不单独复制 SQLite 数据库。") add_bullet(doc, "录像盘与系统告警盘分离 录像盘写满不得影响数据库和操作系统。") add_bullet(doc, "UPS 至少支撑有序关机 监控机柜温度 风扇 电源 GPU ECC 或 Xid 错误。") add_bullet(doc, "生产建议准备第二台可接管服务器 定期验证恢复时间和摄像头重新注册流程。") add_heading(doc, "九 现场测试和验收方案", 1) add_heading(doc, "测试阶段", 2) add_table( doc, ["阶段", "负载", "持续时间", "目的"], [ ["A 媒体基线", "五十路在线 不启动分析和录像", "4 小时", "确认网络 ZLM 和解码前基线"], ["B 解码基线", "五十路分析子码流 仅运动检测", "8 小时", "测 CPU 解码和断流率"], ["C 标准分析", "五十路各 1 fps 单模型", "24 小时", "验证至少 75 次每秒的系统余量"], ["D 分级分析", "四十路 1 fps 加十路 5 fps", "24 小时", "验证推荐运行模式"], ["E 告警风暴", "多目标反复进出 每路模拟告警", "2 小时", "验证截图 数据库和队列"], ["F 录像叠加", "五十路全天录像加分级分析", "24 小时", "验证磁盘和 FFmpeg 进程"], ["G 故障恢复", "断开十路 重启 ZLM 重启服务 单 GPU 故障", "按场景执行", "验证自动恢复和降级"], ["H 稳定性", "实际生产配置全开", "72 小时", "最终验收"], ], widths=[3.3, 6.1, 2.7, 4.1], ) add_heading(doc, "建议验收指标", 2) add_table( doc, ["指标", "通过标准", "采集方式"], [ ["在线路数", "五十路持续在线 短断流可自动恢复", "ZLM 媒体列表和应用状态"], ["每路实际分析 FPS", "P95 通道达到设定值百分之九十五以上", "逐通道一分钟窗口"], ["端到端分析延迟", "1 fps 场景 P95 小于 1.5 秒 关键通道 P95 小于 0.8 秒", "采集时间到结果时间"], ["推理超时", "七十二小时为零", "inference timeout count"], ["GPU 利用率", "持续均值低于百分之八十 显存低于百分之八十五", "nvidia smi 或 DCGM"], ["CPU 和内存", "CPU 持续低于百分之七十五 内存低于百分之八十", "系统监控"], ["告警完整性", "测试事件无丢失 重复率符合规则", "事件脚本与数据库对账"], ["数据库", "无 locked 错误 查询 P95 小于一秒", "日志和接口压测"], ["录像", "随机抽取每路分段均可播放 时间连续", "播放器和 ffprobe"], ["恢复", "单路断流三十秒内恢复 服务重启后自动恢复分析", "故障注入记录"], ["磁盘保护", "达到告警水位后自动清理或停止非关键写入 系统不崩溃", "容量阈值模拟"], ], widths=[3.5, 8.0, 4.7], font_size=8.3, ) add_heading(doc, "必须新增的监控指标", 2) add_bullet(doc, "每路 decode fps analysis fps 目标 fps dropped count stalled seconds reconnect count。") add_bullet(doc, "全局和每 GPU 队列深度 等待时间 推理时间 端到端时间 P50 P95 P99 超时数。") add_bullet(doc, "GPU 利用率 显存 功耗 温度 解码器利用率和错误码。") add_bullet(doc, "告警队列长度 每秒写入数 SQLite 或 PostgreSQL 延迟 数据库错误数 截图写入延迟。") add_bullet(doc, "录像进程数 磁盘吞吐 延迟 可用容量 保留清理耗时和不可播放分段数。") add_heading(doc, "十 实施顺序和采购门槛", 1) add_table( doc, ["顺序", "工作", "完成标志", "是否阻断采购"], [ ["1", "建立单路和五十路可重复压测工具", "输出吞吐 P95 延迟 CPU GPU 和精度报告", "是"], ["2", "异步推理调度和 GPU 分片", "多个 worker 同时在途且保持结果路由正确", "是"], ["3", "分析子码流和硬件解码", "五十路解码 CPU 持续低于百分之六十", "是"], ["4", "TensorRT FP16 和小批次", "吞吐达到推荐负载的一点五倍 精度回归通过", "是"], ["5", "异步告警 PostgreSQL 和截图保留", "告警风暴无丢失 磁盘有水位保护", "生产阻断"], ["6", "自动启动 故障恢复和监控", "重启后无人干预恢复五十路", "生产阻断"], ["7", "目标硬件七十二小时验收", "全部验收指标通过", "最终门槛"], ], widths=[1.5, 6.2, 6.0, 2.5], ) add_para( doc, "采购建议 在第二步和第三步完成前,只能先借用或租用候选 GPU 进行测试,不宜一次性确定最终卡型和数量。" "最终采购技术条款应写成完整链路的最低吞吐 P95 延迟 路数和七十二小时稳定性,而不是只写某个 GPU 型号。", bold_lead="采购建议 ", ) add_heading(doc, "十一 限制条件和待确认问题", 1) add_heading(doc, "本评估的限制", 2) add_para( doc, "本报告基于当前工作区代码 配置 数据库结构 一次开发机参考测试和公开硬件资料。" "没有取得客户现场摄像头的实际码率 编码参数 网络丢包率 目标密度 规则组合和目标服务器,因此硬件型号属于采购候选基线,不构成未经压测的路数保证。" "本地测试使用零图像,目标数量越多时后处理和告警开销通常更高。", ) add_heading(doc, "现场方案冻结前需要回答的问题", 2) questions = [ "五十路摄像头的分辨率 帧率 编码 H264 或 H265 平均和峰值码率是多少。", "是否能为每台摄像头配置独立分析子码流 子码流的最小目标像素尺寸是否仍满足检测。", "每路会绑定几个小模型 是否使用 YOLO26x 还是可用现场微调后的 s 或 m 模型。", "哪些通道需要越线或方向五 fps 哪些通道只需区域一 fps。", "是否启用全天录像 保存几天 是否有集中存储或只能使用本机磁盘。", "是否启用云端或本地大模型复核 允许的告警延迟和外网依赖是什么。", "允许单机故障中断多久 是否要求双机冗余和录像连续性。", "报警截图和报警记录的保存年限 合规审计和删除要求是什么。", ] for q in questions: add_bullet(doc, q) add_heading(doc, "附录 计算方法 测试记录和参考资料", 1) add_heading(doc, "容量计算方法", 2) add_table( doc, ["项目", "计算式", "五十路四 Mbps 示例"], [ ["入口带宽", "路数乘单路码率", "50 乘 4 等于 200 Mbps"], ["每日录像", "路数乘码率乘 86400 除 8", "2.16 TB 每日"], ["七天录像", "每日录像乘 7", "15.12 TB"], ["规划容量", "七天录像乘 1.2", "18.14 TB 可用空间"], ["推理需求", "路数乘分析 fps 乘模型数", "50 乘 1 乘 1 等于 50 次每秒"], ["验收吞吐", "推理需求除 0.67", "约 75 次每秒"], ], widths=[3.8, 6.8, 5.6], ) add_heading(doc, "开发机参考测试记录", 2) add_table( doc, ["项目", "值"], [ ["GPU", "NVIDIA GeForce RTX 5060 Laptop GPU 8151 MiB"], ["驱动", "577.05"], ["PyTorch 和 CUDA", "2.8.0+cu129 CUDA 12.9"], ["模型", "当前数据库默认 YOLO26x detect 640 CUDA 模型文件约 118.7 MB"], ["测试输入", "内存中 1080p 零图像 三次预热 二十次计时"], ["平均耗时", "49.14 ms"], ["中位耗时", "49.03 ms"], ["P95 耗时", "53.15 ms"], ["单请求吞吐", "20.4 次每秒"], ["解释", "不含 RTSP JPEG IPC 和业务处理 属于吞吐上限参考 不是客户现场保证"], ], widths=[4.2, 12.0], ) add_heading(doc, "代码证据索引", 2) add_table( doc, ["证据", "位置"], [ ["默认目标分析帧率五 共享推理和一个 worker", "config.json"], ["每路分析子进程和共享推理路由", "app analysis manager.py"], ["单线程同步推理转发", "app analysis manager.py inference forwarder loop"], ["推理池队列和 worker 模型缓存", "app analysis inference_pool.py"], ["每路 JPEG 质量九十五编码和同步等待", "app analysis remote_detector.py"], ["帧队列最大二 取最新帧 规则强制持续检测", "app analysis pipeline.py"], ["OpenCV VideoCapture FFmpeg 取帧", "app analysis frames.py"], ["SQLite DELETE journal", "app apps.py 和 framework settings.py"], ["每路 FFmpeg 码流复制录像和保留扫描", "app recording manager.py"], ["系统版本和部署约束", "README.md 和 deploy windows DEPLOYMENT.md"], ], widths=[8.0, 8.2], ) add_heading(doc, "公开参考资料", 2) refs = [ ("Ultralytics YOLO26 模型说明和性能范围", "https://docs.ultralytics.com/models/yolo26"), ("NVIDIA TensorRT 性能优化和动态批处理建议", "https://docs.nvidia.com/deeplearning/tensorrt/latest/performance/optimization.html"), ("NVIDIA L4 产品规格", "https://www.nvidia.com/en-gb/data-center/l4/"), ("NVIDIA RTX PRO 6000 Blackwell 产品规格", "https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-6000-family/"), ("Ubuntu 版本支持周期", "https://ubuntu.com/about/release-cycle"), ] for i, (name, url) in enumerate(refs, 1): p = doc.add_paragraph() p.paragraph_format.space_after = Pt(4) r = p.add_run(f"{i} {name} ") set_run_font(r, size=9.5, color=INK) add_hyperlink(p, url, url) p = doc.add_paragraph() p.paragraph_format.space_before = Pt(16) p.alignment = WD_ALIGN_PARAGRAPH.CENTER r = p.add_run("报告结束") set_run_font(r, size=10, bold=True, color=MUTED) # Keep the final output deterministic and ensure paragraphs do not orphan after headings. for paragraph in doc.paragraphs: if paragraph.style and paragraph.style.name.startswith("Heading"): paragraph.paragraph_format.keep_with_next = True for run in paragraph.runs: if run.font.name is None: set_run_font(run) doc.save(OUT) print(str(OUT)) if __name__ == "__main__": build_document()