简介:这份资源面向具备Python基础、对计算机视觉与深度学习有一定了解的研发人员、文物保护技术人员及研究生,提供一套古建筑表面病害辅助检测系统的完整工程实例。系统以YOLO目标检测模型为核心,结合OpenCV图像处理、FastAPI服务接口与SQLite结果存储,可自动识别并定位裂缝、剥落、盐析、霉斑、渗水等常见病害,覆盖图像采集、数据标注、模型训练、推理检测到结果可视化与人工复核的全流程,强调“模型识别+人工复核”的协同模式。资源包为1个docx文档,约112KB,内含完整程序代码、数据库设计与GUI界面设计及代码详解,目录结构清晰,便于按模块查阅。目前已有94人学习。读者可据此动手搭建可复核、可追踪的数字化病害档案,用于日常巡检、修缮前调查与修缮过程质量检查,提升古建筑保护工作的数字化与标准化水平。
1. 古建筑表面病害检测:为什么传统人工巡检越来越不够用
做古建筑保护的人都有一个共同感受:病害不是突然出现的,而是慢慢长出来的。裂缝从发丝宽扩展到几毫米,苔藓从墙角蔓延到整面墙体,风化让砖雕的纹样一点点模糊。问题是,人工巡检的频次远远跟不上病害发展的速度。一个中等规模的古建筑群,两三个技术人员带着相机和记录本走一圈,少则半天多则两三天,回到办公室还要逐张照片比对、标注、归档。等报告出来,现场情况可能又变了。
基于 Python 的古建筑表面病害辅助检测系统,核心思路就是用图像识别把「人眼找病害」这件事部分自动化。拍回来的照片经过预处理、分割、分类,自动框出裂缝、剥落、泛碱、苔藓等典型病害区域,再叠加到建筑立面图上生成病害分布图。它不替代文保专家的判断,但能把「找」和「量」这两步的效率提升一个量级。适合谁用?一是做文保数字化记录的技术团队,二是古建修缮工程的前期勘察人员,三是想用计算机视觉做垂直场景落地但不想碰医疗、工业那些红海方向的开发者。这个方向的数据集相对好获取,病害类型定义清晰,GUI 展示也直观,作为完整项目练手或实际部署都有空间。
2. 从一张照片到病害标注:图像识别流水线怎么搭
2.1 为什么选 OpenCV + scikit-image 做预处理而不是直接上深度学习
很多人一上来就想用 YOLO 或 Mask R-CNN 端到端解决问题。实际做过就知道,古建筑表面病害检测的第一个瓶颈不是模型不够强,而是图像质量太差。古建筑拍摄环境复杂:光线不均匀、墙面纹理干扰、拍摄角度倾斜、尺度变化大。直接拿原始照片训模型,标注成本高不说,模型很容易学到「墙面颜色」而不是「病害特征」。
我一般会先用传统图像处理做一轮预处理和候选区域提取,把明显不是病害的区域排除掉,再把候选区域送进分类网络。这样标注量能减少 60% 以上,模型收敛也快得多。OpenCV 负责几何校正和光照均衡,scikit-image 负责纹理分析和边缘检测,两者配合足够覆盖大部分预处理需求。
import cv2 import numpy as np from skimage import exposure, filters def preprocess_wall_image(img_path, target_size=(1024, 1024)): """ 古建筑墙面图像预处理:几何校正 + 光照均衡 + 纹理增强 img_path: 原始照片路径 target_size: 统一缩放尺寸,便于后续批量处理 """ img = cv2.imread(img_path) if img is None: raise FileNotFoundError(f"无法读取图像: {img_path}") # 1. 尺寸归一化,保持长宽比,短边补齐 h, w = img.shape[:2] scale = max(target_size[0] / w, target_size[1] / h) new_w, new_h = int(w * scale), int(h * scale) img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA) # 2. 限制对比度自适应直方图均衡,解决墙面光照不均 lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) lab[:, :, 0] = clahe.apply(lab[:, :, 0]) img = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) # 3. 非局部均值去噪,保留边缘的同时抑制砖石表面颗粒噪声 img = cv2.fastNlMeansDenoisingColored(img, None, 10, 10, 7, 21) # 4. 自适应伽马校正,暗部提亮但不冲淡裂缝对比度 img = exposure.adjust_gamma(img, gamma=0.8) return img这段代码里几个参数值得展开说。clipLimit=2.0是 CLAHE 的对比度限制阈值,设太高会把墙面正常纹理也放大成假边缘,设太低则暗部裂缝提不出来,2.0 是我在砖石和木构表面都试过比较稳的值。fastNlMeansDenoisingColored的h=10控制去噪强度,古建筑表面本身有颗粒感,这个值再大会把细小裂缝一起抹掉。gamma=0.8是提亮暗部,如果拍摄时曝光正常可以改成 1.0 跳过这步。
2.2 裂缝、剥落、泛碱怎么分:特征工程与分类器选型
预处理之后,下一步是把候选区域找出来。不同病害的图像特征差异其实很大,用同一套特征去描述效果不好。我的做法是分病害类型设计特征,再统一送进分类器。
裂缝的核心特征是线状结构,用 Frangi 滤波器或者基于 Hessian 矩阵的脊线检测最直接。剥落表现为区域性的颜色突变和纹理粗糙度变化,用局部二值模式(LBP)加颜色矩描述。泛碱是白色粉末状覆盖,在 HSV 空间里饱和度和明度都有明显特征,用颜色阈值加形态学操作就能圈出大部分候选。
from skimage.filters import frangi, threshold_otsu from skimage.feature import local_binary_pattern from sklearn.ensemble import RandomForestClassifier import joblib def extract_crack_features(gray_img): """提取裂缝候选区域特征""" # Frangi 滤波增强线状结构,sigmas 覆盖不同宽度裂缝 vesselness = frangi(gray_img, sigmas=range(1, 6), black_ridges=True) # Otsu 自适应阈值二值化 thresh = threshold_otsu(vesselness) binary = vesselness > thresh # 形态学闭运算连接断裂的裂缝段 kernel = np.ones((3, 3), np.uint8) binary = cv2.morphologyEx(binary.astype(np.uint8), cv2.MORPH_CLOSE, kernel) return binary, vesselness def extract_lbp_features(gray_img, region_mask): """提取剥落区域的 LBP 纹理特征""" radius = 3 n_points = 8 * radius lbp = local_binary_pattern(gray_img, n_points, radius, method='uniform') # 只统计候选区域内的 LBP 直方图 hist, _ = np.histogram(lbp[region_mask], bins=np.arange(0, n_points + 3), density=True) return hist # 分类器用随机森林,特征维度不高时比 SVM 更稳 clf = RandomForestClassifier(n_estimators=200, max_depth=12, random_state=42) # 训练时特征向量拼接:Frangi 响应统计量 + LBP 直方图 + 颜色矩Frangi 的sigmas=range(1, 6)对应检测 1 到 5 像素宽的裂缝,实际对应到原图大概 2 到 10 毫米宽的裂缝,覆盖了大部分早期病害。black_ridges=True是因为裂缝在图像里是暗线。LBP 的radius=3和n_points=24是纹理分析的常用配置,再大计算量上去了但区分度提升有限。随机森林的n_estimators=200和max_depth=12是在几千张标注样本上交叉验证调出来的,样本量小的时候可以降到 100 棵树、深度 8,避免过拟合。
2.3 把检测结果落到建筑立面图上:坐标映射与可视化
检测出来的病害区域是在照片坐标系里的,但文保人员需要的是建筑立面图上的病害分布。这中间要做一次透视变换和坐标映射。如果拍摄时放了标定板或者已知尺寸的参照物,映射精度会高很多。没有参照物的话,至少要把照片里建筑的关键角点手动标出来,做单应性变换。
def map_to_elevation(detections, src_points, dst_points, elevation_size): """ 将照片中的检测框映射到建筑立面图坐标系 detections: [(x, y, w, h, label), ...] 照片坐标系 src_points: 照片中建筑四个角点坐标 dst_points: 立面图中对应四个角点坐标 elevation_size: 立面图尺寸 (width, height) """ H, _ = cv2.findHomography(np.array(src_points), np.array(dst_points)) mapped = [] for (x, y, w, h, label) in detections: # 检测框中心点和四角映射 corners = np.array([ [x, y], [x + w, y], [x + w, y + h], [x, y + h] ], dtype=np.float32).reshape(-1, 1, 2) transformed = cv2.perspectiveTransform(corners, H).reshape(-1, 2) # 取映射后的外接矩形 tx, ty = transformed[:, 0].min(), transformed[:, 1].min() tw = transformed[:, 0].max() - tx th = transformed[:, 1].max() - ty mapped.append((int(tx), int(ty), int(tw), int(th), label)) # 绘制到立面图上 canvas = np.ones((elevation_size[1], elevation_size[0], 3), dtype=np.uint8) * 255 color_map = {'crack': (0, 0, 255), 'spall': (0, 165, 255), 'efflorescence': (255, 0, 0), 'moss': (0, 255, 0)} for (x, y, w, h, label) in mapped: cv2.rectangle(canvas, (x, y), (x + w, y + h), color_map.get(label, (128, 128, 128)), 2) cv2.putText(canvas, label, (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color_map.get(label, (0, 0, 0)), 1) return canvas, mappedfindHomography默认用 RANSAC 找最优单应矩阵,四个点对就够解出来。实际用的时候建议标 6 到 8 个点,多出来的点可以用来验证映射误差。立面图尺寸按实际建筑比例设,比如一面 10 米宽 6 米高的墙,用 2000×1200 像素的立面图,映射精度大概在 2 到 3 厘米,对病害定位来说够用了。
3. 数据库与 GUI:让检测结果能查、能看、能导出
3.1 SQLite 表结构设计:病害记录、建筑信息、检测批次怎么关联
检测结果不能只存成图片,得结构化入库才能做统计和追溯。我用 SQLite 是因为它零配置、单文件、Python 内置支持,对于单机部署的辅助检测系统完全够用。表结构设计上分三层:建筑基本信息、检测批次、病害记录。
-- 建筑信息表 CREATE TABLE buildings ( building_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, location TEXT, dynasty TEXT, structure_type TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 检测批次表,每次巡检算一个批次 CREATE TABLE inspection_batches ( batch_id INTEGER PRIMARY KEY AUTOINCREMENT, building_id INTEGER NOT NULL, inspect_date DATE NOT NULL, operator TEXT, weather TEXT, image_count INTEGER DEFAULT 0, FOREIGN KEY (building_id) REFERENCES buildings(building_id) ); -- 病害记录表 CREATE TABLE defects ( defect_id INTEGER PRIMARY KEY AUTOINCREMENT, batch_id INTEGER NOT NULL, defect_type TEXT NOT NULL CHECK(defect_type IN ('crack','spall','efflorescence','moss','other')), severity INTEGER CHECK(severity BETWEEN 1 AND 5), bbox_x INTEGER, bbox_y INTEGER, bbox_w INTEGER, bbox_h INTEGER, area_ratio REAL, image_path TEXT, elevation_x INTEGER, elevation_y INTEGER, note TEXT, FOREIGN KEY (batch_id) REFERENCES inspection_batches(batch_id) ); -- 索引:按建筑和病害类型查是最常用的 CREATE INDEX idx_defects_batch ON defects(batch_id); CREATE INDEX idx_defects_type ON defects(defect_type);severity字段用 1 到 5 表示病害严重程度,这个值可以人工复核时调整,也可以根据面积占比自动初判。area_ratio存病害面积占墙面面积的比例,做趋势分析时直接按时间序列画出来就能看到病害发展速度。elevation_x和elevation_y存映射到立面图上的坐标,前端展示时直接读这两个字段。
3.2 Tkinter 界面布局:从选图到出报告的完整操作流
GUI 用 Tkinter 是因为它跟 Python 集成最顺,打包成 exe 也方便。界面不用做太花哨,核心是让操作人员能快速完成「选建筑 → 选照片 → 跑检测 → 看结果 → 导出报告」这个流程。
import tkinter as tk from tkinter import ttk, filedialog, messagebox from PIL import Image, ImageTk import threading class DefectDetectionGUI: def __init__(self, root): self.root = root self.root.title("古建筑表面病害辅助检测系统") self.root.geometry("1280x800") # 左侧控制面板 control_frame = ttk.Frame(root, width=300) control_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=5) ttk.Label(control_frame, text="建筑名称:").pack(anchor=tk.W, pady=(10, 0)) self.building_var = tk.StringVar() ttk.Entry(control_frame, textvariable=self.building_var).pack(fill=tk.X) ttk.Label(control_frame, text="检测日期:").pack(anchor=tk.W, pady=(10, 0)) self.date_var = tk.StringVar() ttk.Entry(control_frame, textvariable=self.date_var).pack(fill=tk.X) ttk.Button(control_frame, text="选择照片", command=self.select_images).pack(fill=tk.X, pady=10) ttk.Button(control_frame, text="开始检测", command=self.start_detection).pack(fill=tk.X, pady=5) ttk.Button(control_frame, text="导出报告", command=self.export_report).pack(fill=tk.X, pady=5) # 进度条 self.progress = ttk.Progressbar(control_frame, mode='determinate') self.progress.pack(fill=tk.X, pady=10) # 右侧图像显示区 self.image_frame = ttk.Frame(root) self.image_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) self.canvas = tk.Canvas(self.image_frame, bg='gray') self.canvas.pack(fill=tk.BOTH, expand=True) self.image_paths = [] self.detection_results = [] def select_images(self): files = filedialog.askopenfilenames( title="选择巡检照片", filetypes=[("图像文件", "*.jpg *.jpeg *.png *.bmp")] ) self.image_paths = list(files) if self.image_paths: self.show_image(self.image_paths[0]) def start_detection(self): if not self.image_paths: messagebox.showwarning("提示", "请先选择照片") return # 检测放后台线程,避免界面卡死 thread = threading.Thread(target=self._run_detection) thread.daemon = True thread.start() def _run_detection(self): total = len(self.image_paths) for i, path in enumerate(self.image_paths): # 这里调用前面定义的预处理和检测函数 result = self.detect_single(path) self.detection_results.append(result) self.progress['value'] = (i + 1) / total * 100 self.root.update_idletasks() self.root.after(0, lambda: messagebox.showinfo("完成", f"检测完成,共处理 {total} 张照片")) def detect_single(self, img_path): """单张照片检测流程,实际项目里会调用完整的检测管线""" img = preprocess_wall_image(img_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) binary, _ = extract_crack_features(gray) # 后续分类和记录入库逻辑 return {"path": img_path, "defects": []} def show_image(self, path): img = Image.open(path) img.thumbnail((900, 700)) self.photo = ImageTk.PhotoImage(img) self.canvas.delete("all") self.canvas.create_image(450, 350, image=self.photo) def export_report(self): if not self.detection_results: messagebox.showwarning("提示", "没有可导出的检测结果") return # 生成 HTML 报告,包含原图、标注图、病害统计表 report_path = filedialog.asksaveasfilename(defaultextension=".html") if report_path: self._generate_html_report(report_path)界面逻辑里有个关键点:检测过程必须放后台线程。Tkinter 的主循环是单线程的,如果在主线程里跑图像处理,界面会直接卡死,操作人员以为程序崩了。用threading.Thread加root.after回主线程更新 UI 是标准做法。进度条更新用update_idletasks强制刷新,不然进度条不会动。
3.3 检测结果导出:HTML 报告与 Excel 统计表
导出这块我一般做两个格式。HTML 报告给文保人员看,包含每张照片的原图、标注图、病害列表和严重程度。Excel 统计表给管理人员做汇总,按病害类型和严重程度分组计数。
import pandas as pd from datetime import datetime def export_to_excel(detection_results, output_path): """将检测结果导出为 Excel 统计表""" rows = [] for result in detection_results: for defect in result['defects']: rows.append({ '照片文件': result['path'], '病害类型': defect['type'], '严重程度': defect['severity'], '位置X': defect['x'], '位置Y': defect['y'], '宽度': defect['w'], '高度': defect['h'], '面积占比': defect.get('area_ratio', 0), '备注': defect.get('note', '') }) df = pd.DataFrame(rows) # 按病害类型和严重程度做透视统计 pivot = df.pivot_table( index='病害类型', columns='严重程度', values='照片文件', aggfunc='count', fill_value=0 ) with pd.ExcelWriter(output_path, engine='openpyxl') as writer: df.to_excel(writer, sheet_name='病害明细', index=False) pivot.to_excel(writer, sheet_name='统计汇总') return output_pathpivot_table出来的统计汇总表是文保报告里最常被引用的数据。按病害类型分行、严重程度分列,一眼就能看出哪类病害最严重、集中在哪个等级。Excel 用 openpyxl 引擎写,支持多 sheet,不用额外装 xlsxwriter。
4. 避坑与排查:古建筑病害检测里最容易翻车的五个地方
4.1 裂缝检测把砖缝当裂缝:现象、原因与解决
现象:检测结果里出现大量平行短线,人工复核发现是砖墙的灰缝,不是裂缝。原因:Frangi 滤波器对任何线状暗纹都响应,砖缝在图像里跟裂缝的形态特征高度相似。解决:加一个方向一致性约束。裂缝走向通常不规则,而砖缝方向一致且间距均匀。用霍夫变换检测直线,如果候选区域内的线段方向方差小于阈值且间距规律,就判定为砖缝排除。另外可以结合颜色特征,灰缝颜色通常比裂缝浅且均匀。
4.2 光照不均导致大面积误检:现象、原因与解决
现象:墙面一侧被阳光直射、另一侧在阴影里,阴影边缘被检测成剥落区域。原因:CLAHE 虽然能均衡光照,但强阴影边界处的梯度仍然很大,被误判为区域突变。解决:预处理阶段加一步 Retinex 或者同态滤波,把光照分量和反射分量分离,只对反射分量做检测。另外在分类器特征里加入局部对比度统计量,阴影区域的对比度通常低于真实剥落。
4.3 小样本下模型过拟合:现象、原因与解决
现象:训练集准确率 95%,验证集只有 60% 多。原因:古建筑病害标注样本本身少,而且不同建筑、不同病害类型的样本分布极不均匀。解决:一是用数据增强,但要注意古建筑图像的增强不能做水平翻转(砖石纹理有方向性),适合做亮度扰动、小角度旋转、局部裁剪。二是用预训练模型做特征提取,只在后面接一个轻量分类头。三是分层采样,保证每个 batch 里各类病害都有。
4.4 立面图映射坐标对不上:现象、原因与解决
现象:检测框在照片里位置准确,映射到立面图后偏移明显。原因:拍摄时镜头畸变没校正,或者标定的角点不在同一平面上。解决:先用cv2.calibrateCamera做镜头畸变校正,再找角点。角点必须选在建筑同一立面上的实际角点,不能选装饰构件上的点。如果建筑立面不是平面(比如有凹凸),单应变换只能近似,这时候要分区域做局部映射。
4.5 大批量照片处理内存溢出:现象、原因与解决
现象:处理到第几十张照片时程序崩溃,报 MemoryError。原因:每张照片预处理后都保留在内存里,加上中间特征图,几百张 1024×1024 的图很快就把内存吃满。解决:用生成器逐张读取和处理,处理完立即释放。数据库写入用批量提交,每 50 条 commit 一次。GUI 里只保留当前显示的一张图,历史结果只存路径和检测数据,不存图像数组。
5. 进阶技巧:用迁移学习把裂缝检测准确率再提一截
前面讲的传统图像处理加浅层分类器,在病害类型明确、拍摄条件可控的情况下够用。但如果想进一步提升准确率,尤其是面对不同建筑、不同年代、不同拍摄设备带来的域偏移,迁移学习是绕不过去的。我的做法是用一个在大型自然图像数据集上预训练过的卷积网络做特征提取器,把中间层特征拿出来,接一个针对古建筑病害微调的分类头。
具体操作上,不需要重新训练整个网络。把预训练模型的前面若干层冻结,只训练最后几个卷积块和全连接层。学习率设小一点,比如 1e-4,用 Adam 优化器。数据量少的时候,甚至可以把卷积层全部冻结,只训练一个 SVM 或者逻辑回归分类器,效果也比手工特征好不少。
import torch import torchvision.models as models import torch.nn as nn def build_defect_classifier(num_classes=5, freeze_backbone=True): """ 基于预训练 ResNet 的病害分类器 num_classes: 裂缝/剥落/泛碱/苔藓/正常 共 5 类 freeze_backbone: 是否冻结卷积层 """ model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) if freeze_backbone: for param in model.parameters(): param.requires_grad = False # 替换最后的全连接层 in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, 128), nn.ReLU(), nn.Linear(128, num_classes) ) return model # 训练配置 model = build_defect_classifier(num_classes=5, freeze_backbone=True) criterion = nn.CrossEntropyLoss() # 只优化 fc 层参数 optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)freeze_backbone=True的时候只训练最后两层全连接,参数量从一千多万降到十几万,小样本下几乎不会过拟合。如果数据量能到几千张以上,可以把freeze_backbone设成 False,学习率降到 1e-4,做全网络微调。Dropout(0.3)是防止全连接层过拟合,数据越少这个值可以越大,但超过 0.5 会欠拟合。
验证的时候不要只看准确率。病害检测是个不平衡分类问题,正常区域远多于病害区域。要看每类的召回率和精确率,尤其是裂缝这一类,漏检比误检代价大得多。我一般会画混淆矩阵,看裂缝被误分成什么,如果大量裂缝被分成正常,说明分类阈值需要调,或者裂缝样本在训练集里权重不够。
还有一个实际部署时的技巧:把传统方法的高召回率和深度模型的高精确率结合起来。传统方法先圈出所有候选区域,宁可多圈不要漏,然后用深度模型对候选区域做精细分类。这样既控制了计算量,又保证了不漏检。我试过在几百张测试图上,这种组合策略比单独用深度模型端到端检测的 F1 分数高 8 到 10 个百分点。
最后说一个我踩过的坑:模型在实验室数据上表现很好,拿到现场新拍的照片上准确率掉一大截。后来发现是白平衡差异。不同相机、不同光线条件下拍出来的墙面颜色偏差很大,模型学到了颜色而不是纹理。解决办法是在训练时做颜色抖动增强,或者预处理阶段加一步颜色恒常性校正。这个坑让我养成了一个习惯:任何图像模型上线前,一定拿不同设备、不同时间拍的数据做一次交叉验证,不然就是自欺欺人。希望帮到你。
本文还有配套的精品资源,点击获取