
简介这份资源面向具备Python基础、对计算机视觉与深度学习有一定了解的研发人员、文物保护技术人员及研究生提供一套古建筑表面病害辅助检测系统的完整工程实例。系统以YOLO目标检测模型为核心结合OpenCV图像处理、FastAPI服务接口与SQLite结果存储可自动识别并定位裂缝、剥落、盐析、霉斑、渗水等常见病害覆盖图像采集、数据标注、模型训练、推理检测到结果可视化与人工复核的全流程强调“模型识别人工复核”的协同模式。资源包为1个docx文档约112KB内含完整程序代码、数据库设计与GUI界面设计及代码详解目录涵盖项目背景、模型架构、训练评估、服务部署与应用领域等模块。目前已有94人学习。读者可据此动手搭建可复核、可追踪的数字化病害档案用于日常巡检、修缮前调查与过程质量检查提升古建筑保护工作的数字化与标准化水平。1. 从一堆脚手架照片里把病害筛出来去年秋天某文保团队的朋友发来一个压缩包里面是某处木构建筑外檐的 1200 多张巡检照片。他们的问题很具体三个人对着屏幕看了两天只标出 60 多处疑似裂缝和渗水眼睛先扛不住了而且不同人标出来的边界对不上。这不是态度问题是人工逐张判读这件事本身就有上限——光照一变、角度一偏同一道裂缝在两张照片里能长得完全不像。这套基于 Python 的古建筑表面病害辅助检测系统解决的就是这个环节。它用 YOLO 目标检测模型对裂缝、剥落、盐析、霉斑、渗水五类表面病害做自动定位OpenCV 负责图像预处理FastAPI 把推理能力包成接口SQLite 存检测记录再配一套 GUI 做人工复核。它不替代文保人员的判断而是把「从上千张照片里找出可疑区域」这一步从两天压到几十分钟剩下的确认和定级仍然交给人。适合有 Python 基础、想把深度学习落到实际巡检流程里的研发人员和文保技术人员。2. 数据管线怎么搭从原始照片到 YOLO 标注2.1 采集规范与目录约定这套系统最容易翻车的地方不在模型在数据。古建筑表面病害的图像采集如果没有统一规则后面标注、训练、复核全是连锁反应。我一般会先定死三件事拍摄距离分「整体照」和「局部特写」两档整体照覆盖构件全貌特写距离控制在 30 到 50 厘米每张照片的文件名带上建筑编号、构件编号、拍摄日期同一处病害至少保留两个角度的照片避免单视角误判。目录结构按数据集标准来组织YOLO 训练时直接指向这个根目录即可dataset/ ├── images/ │ ├── train/ # 训练集原图 │ ├── val/ # 验证集原图 │ └── test/ # 测试集原图 ├── labels/ │ ├── train/ # 对应 YOLO 格式标注 │ ├── val/ │ └── test/ └── data.yaml # 数据集配置文件这里有个血泪经验images和labels下的文件名必须严格一一对应只差扩展名。YOLO 在训练时是按文件名去匹配标签的如果某张图没有对应标签文件它不会报错而是静默跳过——你以为训了 1000 张实际可能只用了 800 张。2.2 标注规则与类别定义五类病害的标注边界必须写进标注手册否则不同人标出来的框没法用。裂缝按可见连续段标注分叉处拆成多个框剥落按缺失区域外接矩形标注不包含周围变色区盐析按结晶或泛白区域标注边界模糊时以颜色突变线为准霉斑按色斑外轮廓标注渗水按水迹晕染范围标注如果渗水区域内部同时有盐析两个类别各标各的允许框重叠。data.yaml的写法如下path: ./dataset train: images/train val: images/val test: images/test nc: 5 names: 0: crack # 裂缝 1: spalling # 剥落 2: salt # 盐析 3: mold # 霉斑 4: seepage # 渗水nc是类别数names的索引必须和标注文件里的 class_id 对应。如果后面要加木材腐朽、彩绘褪色改这里的同时要检查已有标注文件的 class_id 有没有错位——加在末尾最安全插在中间会让所有旧标签失效。2.3 图像预处理与增强古建筑照片的干扰源比常规检测数据集多得多游客、树木、栏杆、阴影、反光。预处理阶段用 OpenCV 做两件事一是统一缩放到模型输入尺寸二是对过暗图像做轻度 CLAHE 增强。注意是轻度过度锐化会把砖石纹理变成假裂缝。import cv2 import numpy as np def preprocess_image(img_path, target_size640): 读取图像并做统一预处理返回缩放后的图像和缩放比例 img cv2.imread(img_path) if img is None: raise ValueError(f无法读取图像: {img_path}) # 记录原始尺寸后处理时要把框映射回去 h, w img.shape[:2] # 对暗光图像做限制对比度的自适应直方图均衡 # clipLimit 控制对比度增强上限太高会放大噪声 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if np.mean(gray) 80: # 平均亮度低于阈值才增强 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) lab cv2.cvtColor(img, cv2.COLOR_BGR2LAB) lab[:, :, 0] clahe.apply(lab[:, :, 0]) img cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) # 等比例缩放短边对齐 target_size长边按比例 scale target_size / max(h, w) new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) return resized, scaleclipLimit2.0是经验值超过 3.0 在砖石表面容易产生颗粒噪声。np.mean(gray) 80这个阈值不是固定的如果采集环境整体偏暗可以调到 100但不要对所有图像无差别增强——正常曝光的照片增强后反而会丢失细节。训练阶段的增强交给 YOLO 自带的数据增强参数在训练配置里开hsv_h、hsv_s、hsv_v、degrees、translate、scale、flipud、fliplr、mosaic。古建筑病害检测里mosaic要慎用它会把四张图拼在一起裂缝这种细长目标被裁切后可能只剩一小段反而干扰训练。我一般把mosaic设为 0.5 而不是默认的 1.0。3. 模型训练与推理YOLO 参数怎么调才不白跑3.1 训练配置与关键参数训练用 Ultralytics 的 YOLO 接口预训练权重从官方模型加载。古建筑病害数据集通常不大几百到几千张所以冻结骨干网络前几层、用较小学习率是常规做法。from ultralytics import YOLO # 加载预训练模型这里用 yolov8s 平衡速度和精度 model YOLO(yolov8s.pt) results model.train( datadataset/data.yaml, epochs150, # 病害数据集小150 轮通常够收敛 imgsz640, # 输入尺寸小目标多可以提到 960 batch16, # 根据显存调整8G 显存用 8 或 16 lr00.001, # 初始学习率预训练模型微调不宜太大 lrf0.01, # 最终学习率 lr0 * lrf patience30, # 30 轮无提升就早停省时间 device0, # GPU 编号CPU 训练填 cpu projectruns/detect, nameheritage_disease, exist_okTrue, # 数据增强参数 hsv_h0.015, # 色调扰动模拟不同光照色温 hsv_s0.7, # 饱和度扰动 hsv_v0.4, # 亮度扰动 degrees10.0, # 随机旋转角度 translate0.1, # 平移比例 scale0.5, # 缩放比例 fliplr0.5, # 水平翻转概率 mosaic0.5, # 马赛克增强病害检测建议降低 )imgsz是最值得调的参数。裂缝在 640 分辨率下可能只占几个像素宽模型根本学不到纹理特征。如果显存允许直接上 960 或 1280小目标召回率会有明显提升。代价是训练时间和显存占用成倍增加8G 显存跑 960 大概只能开 batch4。patience30配合epochs150是保守组合。实际跑的时候看runs/detect/heritage_disease/results.csv如果 mAP50 在第 80 轮就平了后面 70 轮纯属浪费电。3.2 推理与结果后处理推理阶段的核心不只是拿到框还要把框映射回原图坐标、估算病害面积占比、画可视化结果。import cv2 import numpy as np from ultralytics import YOLO CLASS_NAMES [crack, spalling, salt, mold, seepage] CLASS_CN {crack: 裂缝, spalling: 剥落, salt: 盐析, mold: 霉斑, seepage: 渗水} def detect_disease(model, img_path, conf_thres0.35, iou_thres0.45): 对单张图像做病害检测返回检测结果列表和标注图 img cv2.imread(img_path) h, w img.shape[:2] results model.predict( sourceimg, confconf_thres, # 置信度阈值筛查阶段可降到 0.25 iouiou_thres, # NMS 的 IoU 阈值 imgsz960, # 推理尺寸和训练保持一致 verboseFalse, ) detections [] for r in results: boxes r.boxes for i in range(len(boxes)): cls_id int(boxes.cls[i]) conf float(boxes.conf[i]) x1, y1, x2, y2 boxes.xyxy[i].tolist() # 计算病害框面积占整图面积的比例 box_area (x2 - x1) * (y2 - y1) area_ratio box_area / (w * h) detections.append({ class: CLASS_NAMES[cls_id], class_cn: CLASS_CN[CLASS_NAMES[cls_id]], confidence: round(conf, 4), bbox: [round(x1, 1), round(y1, 1), round(x2, 1), round(y2, 1)], area_ratio: round(area_ratio, 6), }) # 画框和标签 color [(0, 0, 255), (0, 165, 255), (255, 255, 0), (0, 255, 0), (255, 0, 0)][cls_id] cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), color, 2) label f{CLASS_CN[CLASS_NAMES[cls_id]]} {conf:.2f} cv2.putText(img, label, (int(x1), int(y1) - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) return detections, imgconf_thres是业务参数不是技术参数。快速筛查阶段用 0.25宁可多报不可漏报正式归档阶段用 0.5 以上减少人工复核工作量。iou_thres0.45是通用值如果同一区域出现大量重叠框说明标注时边界不一致要回去查标注质量而不是调 NMS。area_ratio这个字段在文保场景里比框坐标更有用。保护人员关心的是「这处剥落占了构件表面的多大比例」而不是像素坐标。但要注意面积占比是二维投影不能直接等同于实际病害面积倾斜拍摄时会有透视误差。系统里把它作为参考值不做定级依据。3.3 训练效果评估与阈值选择训练完成后看results.csv里的metrics/mAP50和metrics/mAP50-95。古建筑病害检测里mAP50 能到 0.7 以上就算可用0.8 以上算不错。但别只看总体 mAP要分类别看——裂缝和盐析的 AP 通常最低因为形态差异大、边界模糊。如果裂缝 AP 明显低于其他类别先查两件事一是标注框是不是太紧把裂缝周围的过渡区也框进去一点反而有助于模型学习二是训练分辨率是不是太低裂缝在 640 下可能只有 2 到 3 个像素宽模型根本看不到。阈值选择上我一般会跑一遍验证集画出不同 conf 下的 precision-recall 曲线找 F1 最高的点作为默认阈值。但实际部署时不会用这个值而是根据业务场景调巡检初筛用低阈值复核归档用高阈值。4. 服务化与数据落库FastAPI 接口和 SQLite 表设计4.1 FastAPI 检测接口模型训好之后不能每次手动跑脚本要包成接口让前端和 GUI 调用。FastAPI 的异步特性和自动文档生成在这里很省事。from fastapi import FastAPI, UploadFile, File, HTTPException from pydantic import BaseModel import sqlite3 import uuid import shutil from datetime import datetime from pathlib import Path app FastAPI(title古建筑病害检测服务) # 全局加载模型避免每次请求都重新加载 from ultralytics import YOLO model YOLO(runs/detect/heritage_disease/weights/best.pt) UPLOAD_DIR Path(uploads) UPLOAD_DIR.mkdir(exist_okTrue) DB_PATH detection.db class DetectionRecord(BaseModel): record_id: str building_id: str image_path: str model_version: str detections: list created_at: str app.post(/api/detect) async def detect( file: UploadFile File(...), building_id: str unknown, conf: float 0.35, ): 上传图像并执行病害检测结果写入数据库 # 保存上传文件用 uuid 避免重名覆盖 record_id str(uuid.uuid4())[:8] suffix Path(file.filename).suffix or .jpg save_path UPLOAD_DIR / f{record_id}{suffix} with open(save_path, wb) as f: shutil.copyfileobj(file.file, f) # 执行检测 detections, annotated detect_disease(model, str(save_path), conf_thresconf) # 保存标注结果图 result_path UPLOAD_DIR / f{record_id}_result.jpg cv2.imwrite(str(result_path), annotated) # 写入 SQLite conn sqlite3.connect(DB_PATH) conn.execute( INSERT INTO detection_records (record_id, building_id, image_path, result_path, model_version, detection_count, detections_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?), (record_id, building_id, str(save_path), str(result_path), yolov8s-v1, len(detections), json.dumps(detections, ensure_asciiFalse), datetime.now().isoformat()) ) conn.commit() conn.close() return { record_id: record_id, building_id: building_id, detection_count: len(detections), detections: detections, result_image: str(result_path), }model在模块加载时初始化一次不要放在接口函数里。YOLO 模型加载要几百毫秒到几秒每次请求都加载会让接口响应时间爆炸。conf作为接口参数暴露出去前端可以根据场景传不同值。4.2 SQLite 表结构与查询SQLite 适合单机部署和中小规模数据不需要额外装数据库服务。表设计上把检测记录和检测详情分开详情用 JSON 字段存查询时用 SQLite 的json_extract函数。-- 建筑档案表 CREATE TABLE IF NOT EXISTS buildings ( building_id TEXT PRIMARY KEY, building_name TEXT NOT NULL, location TEXT, dynasty TEXT, structure_type TEXT, created_at TEXT DEFAULT (datetime(now, localtime)) ); -- 检测记录表 CREATE TABLE IF NOT EXISTS detection_records ( record_id TEXT PRIMARY KEY, building_id TEXT NOT NULL, image_path TEXT NOT NULL, result_path TEXT, model_version TEXT, detection_count INTEGER DEFAULT 0, detections_json TEXT, review_status TEXT DEFAULT pending, -- pending / confirmed / rejected reviewer TEXT, review_note TEXT, created_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (building_id) REFERENCES buildings(building_id) ); -- 按建筑和类别查询的索引 CREATE INDEX IF NOT EXISTS idx_records_building ON detection_records(building_id, created_at);review_status字段是「模型识别 人工复核」模式的落点。模型写入时默认pending保护人员复核后改成confirmed或rejectedreview_note记录修正原因。原始detections_json永远不覆盖复核结果单独存这样后面分析模型误报时有据可查。查询某栋建筑下所有裂缝记录SELECT record_id, image_path, created_at, json_extract(detections_json, $[0].confidence) AS first_conf FROM detection_records WHERE building_id JZ-001 AND detections_json LIKE %crack% AND review_status ! rejected ORDER BY created_at DESC;用LIKE匹配 JSON 字段是权宜做法数据量大了要换成单独的检测详情表。但在几千条记录的规模下这样写最省事。5. 避坑与排查那些让检测结果不可信的细节5.1 现象模型在验证集上 mAP 很高实际巡检照片几乎全漏原因通常是训练集和实际采集的图像分布不一致。训练集可能来自晴天、近距离、标准角度而实际巡检照片有阴天、远距离、倾斜角度、游客遮挡。模型学到的是训练集的拍摄特征不是病害本身的特征。解决在训练集里强制加入困难样本——阴天照片、倾斜角度、有遮挡的照片各占一定比例。如果已经训完了用实际巡检照片跑一遍推理把漏检的图挑出来补标注再微调 20 到 30 轮。不要重新从头训微调就够了。5.2 现象同一道裂缝在不同照片里被识别成不同类别裂缝和霉斑、渗水在低分辨率下视觉特征接近尤其是裂缝内部有深色填充时模型容易混淆。另外标注时如果裂缝和霉斑的边界没有明确规则模型学到的分类边界就是模糊的。解决先查标注一致性随机抽 50 张图让两个人独立标算一下类别一致率。低于 85% 就回去改标注手册。如果标注没问题把输入分辨率从 640 提到 960 或 1280裂缝的纹理特征在高分辨率下才能体现。还可以在训练时对裂缝类别做过采样让模型多见裂缝样本。5.3 现象接口响应越来越慢从几百毫秒涨到几秒原因通常是 SQLite 写锁竞争或者上传目录文件太多。SQLite 在并发写入时会锁库FastAPI 默认多线程处理请求多个请求同时写就排队。另外uploads目录下文件堆积到几万个文件系统遍历也会变慢。解决写入操作加一个简单的队列或者用check_same_threadFalse配合连接池。更直接的办法是把检测和写库拆开检测完先返回结果写库用后台任务。上传目录按月分文件夹uploads/2025-01/、uploads/2025-02/避免单目录文件过多。5.4 现象检测框位置偏移画出来的框和病害对不上如果推理时用了和训练不同的imgszYOLO 内部会做缩放但后处理映射回原图时如果手动改了缩放逻辑坐标就会偏。另一个常见原因是图像 EXIF 方向信息——手机拍的照片带旋转标记OpenCV 读进来不会自动旋转但标注工具可能旋转了导致训练和推理的坐标系不一致。解决推理时的imgsz和训练保持一致。读图后用cv2.imread配合PIL.ImageOps.exif_transpose处理 EXIF 方向确保训练和推理的坐标系统一。如果已经训完了才发现这个问题把训练集全部重新读一遍、统一方向后重新训没有捷径。5.5 现象盐析和正常泛白区域区分不开误报率高盐析在照片上表现为浅色区域但古建筑表面本身就有很多浅色石材、灰浆修补痕迹、正常风化泛白。模型如果只学颜色特征就会把这些全报成盐析。解决标注时盐析要标出结晶颗粒的纹理边界而不是整片浅色区域。训练时加入负样本——正常泛白区域不标任何框让模型学会区分。推理时对盐析类别单独提高置信度阈值比如其他类别用 0.35盐析用 0.5。如果误报仍然高考虑在检测前加一个语义分割步骤先把墙面区域分割出来只在墙面区域内做检测排除天空、树木、地面的干扰。6. 把检测结果变成可追踪的病害档案系统跑通之后真正产生价值的不是单次检测而是把历次检测串成时间线。同一处裂缝在 3 月、6 月、9 月各拍一张系统分别检测后可以按建筑编号和构件编号把记录拉出来对比area_ratio的变化。如果某处裂缝的面积占比从 0.002 涨到 0.005即使绝对值很小也说明它在扩展值得现场复查。实现上不需要复杂的时序数据库在 SQLite 里加一个查询接口就行app.get(/api/building/{building_id}/timeline) async def get_timeline(building_id: str, class_name: str None): 查询某建筑下病害检测的时间线可按类别过滤 conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row sql SELECT record_id, detections_json, created_at, review_status FROM detection_records WHERE building_id ? params [building_id] if class_name: sql AND detections_json LIKE ? params.append(f%{class_name}%) sql ORDER BY created_at ASC rows conn.execute(sql, params).fetchall() conn.close() timeline [] for row in rows: dets json.loads(row[detections_json]) for d in dets: if class_name and d[class] ! class_name: continue timeline.append({ record_id: row[record_id], date: row[created_at][:10], class: d[class_cn], confidence: d[confidence], area_ratio: d[area_ratio], bbox: d[bbox], review_status: row[review_status], }) return {building_id: building_id, count: len(timeline), timeline: timeline}这个接口返回的数据可以直接喂给前端画折线图横轴是日期纵轴是area_ratio每条线是一个病害类别。保护人员一眼就能看出哪类病害在恶化、哪类保持稳定。还有一个容易被忽略的点模型版本要记录。每次重新训练后best.pt会覆盖如果不记录版本号后面发现某批检测结果异常时根本不知道用的是哪个模型。我一般会在训练完成后把权重文件重命名成带日期和 mAP 的格式比如yolov8s_20250115_map0.78.pt数据库里存这个文件名。从那以后我每次部署新模型前都会先用同一批验证集照片跑一遍新旧模型的对比确认新模型在各类别上都没有明显退步才上线。模型更新不是越新越好有时候新数据引入的噪声会让某个类别反而变差。希望帮到你。本文还有配套的精品资源点击获取