RAG 文档解析(三):bbox 实战——多栏排版与水印 PDF

系列文章:(一)PDF 三层实战 | (二)PPT/手写笔记 OCR | 源码:GitHub 仓库

前四层我们只关心一件事:文字是什么。

从这一层开始,要关心第二件事:文字在哪里。

每个文字块在页面上都有位置坐标 (x0, y0, x1, y1)------这就是 bbox(bounding box,坐标框) 。有了它,才能解决两个经典难题:

  • 多栏排版 :报纸/杂志风格,extract_text() 会把左栏右栏读串
  • 带水印 PDF:半透明水印干扰 OCR,要在像素/对象阶段就拦掉

场景 1:多栏排版 PDF

痛点:文字读串了

看这个双栏页面:

复制代码
┌────────────────────────────────┐
│ 左栏               右栏         │
│ 苹果是一家科技公司, 橙子富含维生素C,│
│ 总部位于库比蒂诺,   是很多人喜爱的水果,│
│ 由乔布斯创立。      常用于榨汁。   │
└────────────────────────────────┘

用 page.get_text() 提取,拿到的是:

苹果是一家科技公司,橙子富含维生素C,总部位于库比蒂诺,是很多人喜爱的水果,由乔布斯创立。常用于榨汁。

两栏内容完全搅在一起。为什么?因为 PDF 内部存储文字接近"绘制顺序",不是阅读顺序------它根本不知道"栏"的存在。

人类怎么一眼看出这是两句?因为眼睛在看每行文字的起点位置(x 坐标) :左栏文字都从一个 x 位置附近开始,右栏是另一个。

程序无法判断语义,只能靠坐标------这就是引入 bbox 的动机。

bbox 从哪来?

PyMuPDF 的 page.get_text("blocks") 返回 7 元组:

arduino 复制代码
(x0, y0, x1, y1, "文字内容", 块编号, 块类型)
  • x0, y0 = 左上角;x1, y1 = 右下角
  • 块类型:0 = 文字块,1 = 图片块(图片块第 5 位不是文字!)

算法:递归 XY-cut

怎么自动还原阅读顺序?工业界经典算法 XY-cut,三步逻辑:

markdown 复制代码
1. X 切:把块往 y 轴投影,找"横贯整页的空白带" → 上下分段
2. Y 切:在每段内往 x 轴投影,找"纵贯的空白带" → 左右分栏
3. 交替递归:切不动(横竖都无空白带)→ 原子区域,按 (y0, x0) 排序

直观理解:

  • 双栏页面:Y 切能在中线附近找到纵向空白带 → 切成左栏、右栏两组
  • 跨栏大标题:X 切先把它单独切成"上段",排在两栏之前
  • 一段连续文字:横竖都切不开 → 原子区域,内部按先上后下排序

"找空白带"的本质是区间重叠分组:把块按某轴排序后扫描,下一个块的起点落在当前组区间结束之前 → 同组;否则中间有空白带 → 封组开新组。

代码实现

新建 parsers/pdf_ocr_bbox.py:

python 复制代码
"""
第五层 · 场景 1:多栏排版 PDF --- XY-cut 生产级阅读顺序还原
"""
import pymupdf


def group_by_axis(blocks: list, sort_idx: int, end_idx: int, margin: float = 5.0) -> list:
    """
    将块列表按某一轴分组:投影区间重叠的块归为一组
    :param sort_idx: 排序用的坐标下标(y 轴=1 即 y0;x 轴=0 即 x0)
    :param end_idx: 区间结束下标(y 轴=3 即 y1;x 轴=2 即 x1)
    :param margin: 区间腐蚀量(实测修正,见翻车 1):块 bbox 自带内边距,比实际
                   文字大几个点,相邻块会"虚重叠";先把区间两头各缩 margin 个点
                   再判重叠,虚重叠消失、真实空隙才露出来
    :return: [[组1的块...], [组2的块...], ...]  组与组之间存在空白带
    """
    # 先排序,再逐块扫描,才能知道"下一个块"是谁
    blocks = sorted(blocks, key=lambda b: b[sort_idx])
    groups = []
    current = [blocks[0]]
    max_end = blocks[0][end_idx]  # 当前组在该轴上到达的最远位置
    for b in blocks[1:]:
        # b 的起点落在当前组区间结束之前(先腐蚀 margin)→ 真实重叠 → 同组
        # 否则中间有空白带 → 封组,开新组
        if b[sort_idx] < max_end - margin:
            current.append(b)
            max_end = max(max_end, b[end_idx])  # 组的区间可能被新块撑大
        else:
            groups.append(current)
            current = [b]
            max_end = b[end_idx]
    groups.append(current)  # 最后一组别忘收尾
    return groups


def xy_cut_order(blocks: list, mode: str = "x", margin: float = 5.0) -> list:
    """
    递归 XY-cut:输入带 bbox 的块列表,输出按阅读顺序排列的块列表
    :param mode: "x"=找横向空白带(上下分段);"y"=找纵向空白带(左右分栏)
    :param margin: 区间腐蚀量,透传给 group_by_axis
    """
    # 递归出口:没块或只剩一个块 → 不用再切
    if len(blocks) <= 1:
        return blocks

    if mode == "x":
        # X 切:按 y 轴分组(y0 下标 1,y1 下标 3)
        groups = group_by_axis(blocks, sort_idx=1, end_idx=3, margin=margin)
        if len(groups) == 1:
            # 横着切不开 → 换方向,尝试 Y 切(找栏间距)
            return xy_cut_order(blocks, mode="y", margin=margin)
        result = []
        for g in groups:                              # 上段 → 下段
            result.extend(xy_cut_order(g, mode="y", margin=margin))  # 段内继续左右分栏
        return result
    else:
        # Y 切:按 x 轴分组(x0 下标 0,x1 下标 2)
        groups = group_by_axis(blocks, sort_idx=0, end_idx=2, margin=margin)
        if len(groups) == 1:
            # 横竖都切不开 → 原子区域,按 (y0, x0) 排序:先上后下、先左后右
            return sorted(blocks, key=lambda b: (b[1], b[0]))
        result = []
        for g in groups:                              # 左栏 → 右栏
            result.extend(xy_cut_order(g, mode="x", margin=margin))  # 栏内继续上下分段
        return result


def load_pdf_multicolumn(pdf_path: str, page_marker: str = "页") -> str:
    """多栏排版 PDF 专用解析器(XY-cut 阅读顺序还原)"""
    pdf_data = pymupdf.open(pdf_path)
    page_results = []
    for i, page in enumerate(pdf_data):
        raw_blocks = page.get_text("blocks")
        valid_blocks = []
        for b in raw_blocks:
            # type 0=文字块(直接保留);type 1=图片块(第 5 位不是文字)
            # 图片块统一替换成占位符 [图片],让它参与排序"占住位置",
            # 这样图片前后的文字顺序才不会被搞乱
            if b[6] == 0:
                valid_blocks.append(b)
            elif b[6] == 1:
                valid_blocks.append((b[0], b[1], b[2], b[3], "[图片]", b[5], 1))

        ordered = xy_cut_order(valid_blocks, mode="x")  # 从 X 切开始

        # 按排好的顺序取每块的第 5 位(文字),过滤空块,join 起来
        texts = [str(b[4]).strip() for b in ordered if str(b[4]).strip()]
        page_content = f"--- 第 {i + 1} {page_marker} ---\n" + "\n".join(texts)
        page_results.append(page_content)
    return "\n\n".join(page_results)


if __name__ == "__main__":
    result = load_pdf_multicolumn("../test_files/我国省级政府在线政务服务渠道发展水平与地域分异研究_上官莉娜.pdf")
    print(result[:5000])

运行结果

三个设计细节

细节 处理 原因
图片块 替换成 [图片] 占位符参与排序 占住位置,图片前后文字顺序才不乱
跨栏大标题 X 切先切成独立"上段" 标题自然排在两栏之前
递归出口 len(blocks) <= 1 直接返回 原子区域无需再切

生产级补充:中部通栏的图/表格,MVP 用"跨栏块置顶"兜底;更严谨的做法是完整递归 XY-cut,能把它放回正确位置。
代码里 margin=5.0 不是拍脑袋加的------块 bbox 有内边距会导致相邻块"虚重叠",把左右栏焊死;腐蚀量还得卡在"行隙 < margin < 栏隙"之间。这是两次实测踩坑后定下的,详见下方翻车 1 / 翻车 2。

场景 2:带水印 PDF

先分类:水印有两种存在形式

形式 来源 本质 解法
A:矢量水印 Word 插入水印后导出 PDF 页面图层上的对象,有 bbox/字号/颜色 按特征精确过滤 ✅
B:烧死的水印 盖章/水印纸扫描成图片 PDF 和正文熔在同一张图的像素里 图像预处理抹掉 ⚠️

两种情况解法完全不同,所以第一个问题是:怎么判断手里是哪种?

答案还是 bbox 那套:get_text("blocks") 能提取到的都是文字对象;提取不到但看得见的一定在图片里。有文字层 → 路线 A;没文字层(扫描件)→ 路线 B。

路线 A:矢量水印的特征过滤

人类一眼认出水印靠四个特征:颜色浅、字号大、斜 45°、一页重复好几次。程序要把它们变成数字。

get_text("blocks") 拿不到颜色和字号,但 get_text("dict") 可以------它返回更细的 span ,每个 span 带 color(sRGB 整数)和 size(字号)。浅灰色的 sRGB 整数偏大(0x000000 是黑,0xFFFFFF 是白),拆出 RGB 求平均亮度即可量化。

路线 B:烧死水印的像素过滤

扫描件里,正文黑字灰度接近 0,浅色水印灰度接近 200+。二值化(大于阈值→白,小于等于→黑)能让水印"隐形",再喂 OCR。

代价:灰色页眉、浅红公章等浅色正常内容会被一起抹掉。所以阈值默认 190(只抹"很浅"的),并做成可配置参数。

为什么"先去水印再 OCR"而不是"OCR 完再清洗文本"?

想象 OCR 已经把斜着的水印识别出来了,混在正文行里。这时想用规则删掉它------但 OCR 出的水印文字可能识别错("机密"变"札宓"),规则还能可靠匹配吗?

错误在流水线中出现得越早、被拦截得越早,处理代价越小。 水印在像素/对象阶段去掉,OCR 连见都没见到它;等 OCR 输出再删文本,面对的是已经识别错的垃圾------删不干净,还会误删正文。(呼应第一篇主旨:Garbage in, garbage out。)

代码实现

新建 parsers/pdf_ocr_sy.py:

python 复制代码
"""
第五层 · 场景 2:带水印 PDF --- 双路线去水印
"""
import pymupdf
from rapidocr_onnxruntime import RapidOCR
import numpy as np
import cv2
from collections import Counter


def is_watermark_span(span: dict,
                      repeat_count: int,
                      color_threshold: int = 160,
                      size_threshold: float = 40.0,
                      min_repeat: int = 3) -> bool:
    """
    判断单个 span 是否为水印
    :param repeat_count: 该 span 文本在本页出现的次数(外层统计传入)
    """
    text = span.get("text", "").strip()
    if not text:
        return True  # 空 span 直接丢弃

    # ── 特征①:颜色浅(强特征,单独成立)──
    # color 是 0xRRGGBB,位运算拆通道;亮度=三通道平均,越大越浅
    color = span.get("color", 0)
    r = (color >> 16) & 255
    g = (color >> 8) & 255
    b = color & 255
    brightness = (r + g + b) / 3
    if brightness > color_threshold:  # 黑字 color=0 → 亮度 0,绝不误杀
        return True

    # ── 特征②+③:弱特征必须组合(实测翻车后的修正版)──
    # 中英文混排时 "RAG" 因字体切换总独立成 span、一页出现 5 次,
    # 按"重复即水印"会误杀正文;黑色大标题字号大但也是正文。
    # 所以:超大字号 AND 短文本重复,同时命中才判水印
    is_huge = span.get("size", 0) > size_threshold
    is_repeat = len(text) <= 10 and repeat_count >= min_repeat
    if is_huge and is_repeat:
        return True

    # 进阶扩展:line["dir"] != (1, 0) 说明文字被旋转,
    # 45° 水印的 dir ≈ (0.707, 0.707),可再加一条旋转判断
    return False


def remove_watermark_by_threshold(img: np.ndarray, threshold: int = 190) -> np.ndarray:
    """灰度二值化:浅色像素(水印)→ 白,深色像素(正文)→ 黑"""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    # THRESH_BINARY:大于阈值 → 255(白),小于等于 → 0(黑)
    # 极性搞反会把正文刷成背景------二值化最经典的坑
    _, binary = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
    return cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)  # 转回三通道,RapidOCR 才能吃


def extract_text_filter_watermark(page) -> str:
    """有文字层的页面:get_text("dict") → 过滤水印 span → 拼接正文"""
    data = page.get_text("dict")

    # 第一遍:收集所有 span,统计每个文本在本页出现的次数
    # (特征③依赖全局统计,必须先收集再判断,不能边遍历边过滤)
    all_spans = []
    for block in data["blocks"]:
        if block.get("type") != 0:  # 只看文字块
            continue
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                all_spans.append(span)
    counter = Counter(s["text"].strip() for s in all_spans if s["text"].strip())

    # 第二遍:按原始顺序过滤水印 span,拼接正文
    lines_text = []
    for block in data["blocks"]:
        if block.get("type") != 0:
            continue
        for line in block.get("lines", []):
            parts = [s["text"] for s in line.get("spans", [])
                     if not is_watermark_span(s, counter.get(s["text"].strip(), 0))]
            if parts:
                lines_text.append("".join(parts))
    return "\n".join(lines_text)


def load_pdf_watermark(pdf_path: str,
                       text_threshold: int = 500,
                       gray_threshold: int = 190,
                       render_zoom: float = 2.0) -> str:
    """
    带水印 PDF 专用解析器(双路线)
    :param gray_threshold: 路线 B 二值化阈值(只抹浅色水印)
    :param render_zoom: 路线 B 渲染放大倍数(2.0 = 144 DPI)
    """
    pdf_data = pymupdf.open(pdf_path)
    ocr = RapidOCR()
    page_results = []
    for i, page in enumerate(pdf_data):
        text = page.get_text()
        if len(text) > text_threshold:
            # 路线 A:有文字层 → span 特征过滤,精确无损
            page_content = extract_text_filter_watermark(page)
        else:
            # 路线 B:扫描件 → 渲染 → 二值化抹水印 → OCR
            pix = page.get_pixmap(matrix=pymupdf.Matrix(render_zoom, render_zoom))
            img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
            img = remove_watermark_by_threshold(img, gray_threshold)
            result, _ = ocr(img)
            ocr_lines = []
            if result:
                for line in result:
                    item = line[1]
                    ocr_lines.append(item[0] if isinstance(item, tuple) else str(item))
            page_content = "\n".join(ocr_lines)
        page_results.append(f"--- 第 {i + 1} 页 ---\n{page_content}")
    return "\n\n".join(page_results)


if __name__ == "__main__":
    # 路线 A:Word → 设计 → 水印 → 自定义水印"机密" → 导出 PDF
    # result = load_pdf_watermark("../test_files/RAG-水印.pdf")
    # 路线 B:把带水印的纸打印出来再扫描,或输出成图片版 PDF
    result = load_pdf_watermark("../test_files/烧死的水印.pdf", gray_threshold=190)
    print("路线B:", result[:1000])

运行结果

线路A

线路B

实测翻车记录(本文最值钱的部分)

翻车 1:XY-cut 把左右栏"焊死"了------区间腐蚀救场

单栏论文跑通后,换一篇真正的双栏论文《我国省级政府在线政务服务渠道发展水平与地域分异研究》验证,第 3 页翻车------左右栏文字交错输出,一行"左栏半句"接一行"右栏半句"。

做控制实验(打印第 3 页全部 87 个块的坐标),真凶有两个,都指向同一件事:pymupdf 的块 bbox 自带内边距,比实际文字大几个点,导致相邻块"虚重叠" 。

# 发现 后果
1 页头 URL 块 y=47,67、正文首行 y0=64,"重叠"仅 3pt(假的) X 切找不到横贯整页的空白带 → 切不出上下段
2 左栏止于 x1=287、右栏始于 x0=308,真实栏间隙 21pt;URL 块 x=273,322 一头扎进间隙 Y 切时 URL 块和左右栏都重叠 → 左右栏并进同一组 → 整页退化成按 (y0,x0) 排序 → 交错

修复:区间腐蚀(margin erosion)。 分组前把每块区间两头各缩 margin=5pt,重叠判断从 b[sort_idx] < max_end 改成 b[sort_idx] < max_end - margin。3pt 的虚重叠被腐蚀掉,21pt 的真实栏间隙毫发无损。

验证项 结果
政务服务论文第 3 页(此前交错) ✅ 左栏从"自2016年..."连续读到"...参考借鉴。",右栏整体在后,顺序完全正确
单栏论文(回归测试) ✅ 与修复前一致,无损

顺带纠正一个误判: 之前以为跑通的那篇论文是双栏,实测它正文块横贯整页(x1≈531,页宽 595),右上角只是页眉小字------其实是单栏。单栏页 X 切、Y 切都切不开,退化成按 (y0,x0) 排序,XY-cut 天然兼容单栏、不会切坏。所以它的"回归"验证的是"单栏不被切坏",真正考验分栏能力的是双栏的政务服务论文。

翻车 2:三栏却变成"横着读"------margin 的隐含前提

两栏搞定,那三栏呢?报纸、学术海报、部分年报都是三栏。先说结论:group_by_axis 是"按区间重叠分组"、不是二分,Y 切切出几组就递归处理几组,双栏只是"组数=2"的特例,理论上三栏、四栏都不用改代码。

实测却翻车了。 用 pymupdf 造一个三栏测试 PDF(通栏标题 + 三栏各 8 行,每行带"【N栏-NN】"编号),第一次行距设了 22pt,输出变成"横向逐行读":【1栏-01】【2栏-01】【3栏-01】【1栏-02】......

打印块坐标做控制实验:造的 PDF 块 bbox 紧凑、没有虚重叠,行隙 10pt 大于腐蚀量 5pt → X 切把每一行都切成"横贯整页的横带",每个横带内再左右排序 → 变成表格式横读。

修正: 按真实文档比例重造(行距 14pt → 行隙约 4pt;栏隙 35pt)→ PASS ✅。输出顺序:标题 → 1 栏 18 行 → 2 栏 18 行 → 3 栏 1~8 行。

这次翻车暴露了 margin 腐蚀的隐含前提,正好和翻车 1 的"5 是实测平衡点"闭环:

css 复制代码
行隙 < margin < 栏隙
  • 真实文档天然满足:行块 bbox 带内边距 → 行间虚重叠约 8pt(腐蚀后切不开行);栏隙 15~30pt(腐蚀后切得开栏)。
  • 程序生成的 PDF 可能违反:bbox 紧凑、行距偏大 → 行隙超过 margin,行被当成"段"切开。

为什么 margin 不调大到 10,把页脚页码也切出去? 实测同栏相邻行的虚重叠有 7~8pt)"和"跨区虚重叠(3pt)"之间------5 是实测平衡点。(遗留小瑕疵:页脚页码与左栏末行虚重叠 8pt,超过腐蚀量,会贴在左栏末尾,对 RAG 切片无害,进阶可用"页脚检测"处理。)
结论:XY-cut 能处理任意栏数,但用之前要先确认文档满足 行隙 < margin < 栏隙------这是排版特征,不是代码能自动兜底的。

翻车 3:去了水印,识别反而变差了?

路线 B 第一版跑完:水印确实没了,但正文一堆形近字错误,甚至整行丢失:

原文 OCR 识别结果 错误类型
第一步------文档解析 第-步一-文精解析 "一"→"-";"档"→"精"
打个比方 打个比力 "方"→"力"
混在一起 坚混作一起 "在"→"坚"、"一"→"作"
蔬菜 藏菜 "蔬"→"藏"
RAG 的流程大家都熟悉:文档 → 切片 → ... (整行丢失) 疑似与行内"→"有关

三个嫌疑:二值化副作用、渲染分辨率不足、特殊符号难识别。

对照实验:把 DPI 从 72 提到 144(render_zoom=2.0),其他不动:

对比项 72 DPI(改前) zoom=2.0(改后)
含"→"的那一行 整行丢失 ✅ 完整识别(含箭头)
第一步------文档解析 第-步一-文精解析 ✅ 基本正确
打个比方 打个比力 ✅ 正确
混在一起的蔬菜 坚混作一起的藏菜 ✅ 正确
表格页(全景图表) 基本没识别出来 ✅ 行列内容都出来了
仍存在的错误 ------ "分拣"→"分抹"、"检索"→"检素" 等少量形近字

结论:渲染分辨率才是主因,二值化的副作用比预想小得多。 6 处错误修好 5 处,连丢失的整行和整个表格都找回来了。剩余形近字错误是 OCR 引擎本身的水平,属于正常损耗。

排查顺序的经验:先提 DPI 做对照,再怀疑预处理------别一上来就怪二值化。

翻车 4:正文里的 "RAG" 被当水印误杀了

路线 A 第一版跑完:"机密"出现 0 次,差点宣布过滤成功。做控制实验(直接 get_text 看原始数据)后真相大白:

发现 1:"机密"根本不在文字层! Word 的艺术字水印导出 PDF 后是矢量图形 而不是文字对象,get_text 从来就看不见它。"机密=0"不是被过滤了,是本来就没有。

推论:对有文字层的 PDF,Word 原生水印不会污染文本输出;路线 A 防的是"文字型水印"(少数工具生成);图形水印只有走 OCR(路线 B)时才会被渲染进图里,靠二值化抹掉。

发现 2:标题"RAG文档解析"变成了"文档解析",正文 5 处 RAG 全消失。 颜色分析排除误杀(所有文字都是纯黑 0x0)。真凶是特征③"重复出现":中英文混排时字体切换导致 "RAG" 总是独立成 span、一页出现 3+ 次 → 被当成重复水印误杀。同理被误杀的还有"→""="和项目符号。

修正:弱特征不能一票否决,必须组合。

markdown 复制代码
旧规则:命中 ①浅色 / ②超大字号 / ③重复出现 任一个 → 判水印
新规则:①浅色是强特征,单独成立;
        ②和③是弱特征,必须同时命中(超大字号 AND 短文本重复)才判水印

修正后实测:RAG 恢复(7 次),"→""="全部找回,"机密"仍为 0。

过滤规则要同时防两种错误: 假阴性 (该删的没删)------先确认目标在原始数据里真的存在,再谈"删除成功"; 假阳性(不该删的删了)------拿原文逐词对照,尤其盯紧高频短词和小符号。

小结

场景 核心思路 关键函数
多栏排版 递归 XY-cut + 区间腐蚀还原阅读顺序 xy_cut_order() / group_by_axis()
矢量水印 span 特征过滤(浅色=强特征) is_watermark_span()
烧死水印 二值化抹浅色像素 + 高 DPI 渲染 + OCR remove_watermark_by_threshold()

本篇核心经验:

  1. bbox 是布局理解的地基:程序不懂语义,只懂坐标
  2. 块 bbox 有内边距,相邻块会"虚重叠" :分组前要区间腐蚀,腐蚀量卡在"行隙 < margin < 栏隙"之间
  3. 算法的隐含前提要主动验证:XY-cut 换到三栏、或程序生成的 PDF,前提就可能被打破
  4. 错误拦截越早代价越小:水印要在进 OCR 之前拦掉
  5. 强特征单独成立,弱特征必须组合:否则误杀正文
  6. 先对照实验再下结论:DPI 才是识别率主因,不是二值化
  7. 宣称"成功"前先做控制实验:确认目标真的存在过

下一篇预告: 第六层------特殊表格/文书:发票票据的二维码解码与关键字段定位、合同签名公章处理。源码持续更新:GitHub 仓库

相关推荐
FYKJ_20101 小时前
springboot雅集社区养老管理系统04456-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
zhanghaha13141 小时前
Python进阶教程:35_PyQt (保姆级零基础教程)
开发语言·python·pyqt
信誓旦旦的程序猿2 小时前
【Python 量化取数指南 #09】Python 拿港股通数据:港股通成交与港股财报实测
java·python·股票数据api·股票数据·股票数据api接口·股票api数据接口·股票量化数据api
FYKJ_20102 小时前
springboot羽毛球场地管理系统00626-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
Wx-bishekaifayuan2 小时前
django医院营收信息预测系统49414-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
Wx-bishekaifayuan2 小时前
springboot会议室预约管理系统42030-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
IT毕设梦工厂2 小时前
计算机毕业设计选题推荐:基于大数据的房地产交易数据分析与可视化|毕业设计选题|计算机毕设|选题推荐|毕设指导|项目定制|源码|高质量项目
大数据·hive·hadoop·python·数据分析·spark·课程设计
Wx-bishekaifayuan2 小时前
springboot生活商城系统21035-计算机课程设计、毕业设计
spring boot·后端·python·spring·elasticsearch·golang·课程设计
FYKJ_20102 小时前
django药饮个性化推荐系统54839-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django