长卷里的人都“活”了,但我们需要换一个角度看它。当《仇英版清明上河图》被当作一幅描绘明代市井生活的图像大全时,很多人的第一反应是感叹画面精美、人物众多。可如果你是一个做图像处理、数据可视化或者数字人文研究的技术人,你大概率会冒出另一个问题:这十米长卷里画了2200多个人,人物姿态、行当全不重样,能不能用工程化方法把它们“数清楚、分好类、画成图”?
这就是本文想展开的话题:以仇英《清明上河图》的数字化分析为场景,梳理一套从图像采集、人物标注、姿态与行当分类,到可视化呈现的完整实操方案。我们不会只聊艺术史,而是把这张画当作一个“高密度目标检测数据集”,站在计算机视觉和数据分析的角度拆解它的信息量。整个过程涉及 OpenCV 目标检测、分类标签体系设计、长卷切图策略、标注格式转换和数据可视化,适合正在做图像处理项目、数字人文研究,或者单纯想用技术方式“围观”古画细节的开发者。
1. 画作背景与数字化分析的切入点
1.1 为什么是仇英版《清明上河图》
提到《清明上河图》,大部分人先想到的是北宋张择端的版本。那幅画以汴京郊野到市区的景象为线索,描绘了北宋都城的生活百态。而仇英版本的《清明上河图》虽然沿用同名,描绘的却是明代苏州城的繁华场景,整体风格更偏向明代中后期的江南市井生活:虹桥、城楼、商铺、戏台、婚嫁队伍、各式手工艺人,内容比张择端版本更强调“世俗热闹”。
更关键的一点在于,仇英版在人物数量和细节密度上极有代表性。十米左右的绢本长卷上密布人物,有说法称全卷人物超过两千人,而且姿态各异、行当丰富。有人赶车、有人挑担、有人对弈、有人看戏、有人买卖、有人闲聊,几乎每个人物都带着独立的动作信息。这个特征让它在图像分析里成了一个极有价值的研究对象:画面信息密度高、人物重叠多、目标尺度差异大、场景纹理复杂。
1.2 从图像研究者的角度看这张画
如果我们把《仇英版清明上河图》看作一个“数据源”,它有几点很值得注意:
- 图像尺寸极大,十米长卷即使按 300 DPI 扫描,也会得到一张横向像素可能超过 100000 像素的巨图。
- 人物目标非常多,而且大量人物只有几十到上百像素高,属于典型的极小目标检测场景。
- 长卷是连续场景,光照相对统一,但建筑、树木、桥梁等背景会对人物检测产生干扰。
- 人物衣冠、姿态差异明显,区分行当需要结合上下文场景信息,而不仅仅是人形框。
因此,数字化分析这幅画不能只靠传统的目标检测模型“拉个框”,还需要配合合理的图块切分、标注规范和领域知识。
1.3 文章将要实现的整体流程
本文的实战流程可以概括为:
- 从公开高清图源获取画作扫描图。
- 将长卷切成适合模型推理的图块(tile)。
- 使用目标检测模型或人工标注方式给每个人物生成边界框。
- 建立行当、姿态、动作的标签体系。
- 对标注结果做数据清洗和统计。
- 用 Python 做可视化,输出人物分布、行当构成、动作类型等结论。
下面会逐步给出每个环节的可运行示例,重点讲清楚为什么要这么设计。
2. 环境准备与数据说明
2.1 技术栈与版本思路
为了避免版本兼容问题,本文不锁定精确版本号,而是给出一个经过验证的组合思路。你可以根据自己的实际环境安装,下面列出的是相对稳定的选型方向:
- Python 3.9 或更高版本。
- OpenCV:用于图像读取、切块、缩放和基础处理。
- PyTorch:如果用现代目标检测模型,需要配套的深度学习框架。
- LabelImg 或 X-AnyLabeling:用于人工标注,生成 PASCAL VOC 或 COCO 格式。
- Pandas、Matplotlib、Seaborn:用于统计分析和可视化。
- NumPy:数组计算。
- Pillow:处理超大图的另一种选择。
2.2 数据集说明
本文不直接提供画作高清扫描文件,因为版权和来源需要授权。建议读者选用博物馆公开的数字资源,比如台北故宫博物院 Open Data 专区,或合法开放的在线艺术档案。
代码示例中以一个模拟的文件夹结构为例:
qingming_chouying/ ├── raw/ │ ├── tile_0001.jpg │ ├── tile_0002.jpg │ └── ... ├── annotations/ │ ├── coco_total.json │ └── labels.txt ├── models/ │ └── README.md ├── scripts/ │ ├── split_long_image.py │ ├── detect_person.py │ ├── merge_results.py │ └── visualize_stats.py └── output/分块的原因是:深度学习模型通常无法直接处理几万像素宽的整张长图,即便能处理,计算量也大到不现实。所以切块策略是整个流程的第一步。
3. 核心思路:超长画的切块与坐标还原
3.1 为什么要切块
一个正常的检测模型输入尺寸通常为 640×640 或 1280×1280。如果把整张十米长卷直接塞进去,图像会被严重压缩,人物细节丢失,检测结果几乎没有参考价值。就算使用大模型,GPU 显存也可能不够。
切块之后需要注意坐标还原:模型在小图上检测到目标的坐标,需要映射回原长卷坐标,否则无法统计全局分布。
3.2 滑窗切块实现
下面是一个基于 OpenCV 的长卷切块脚本。它会按指定步长滑动裁剪,并把每个图块的原点坐标写入文件名,方便后续还原。
import cv2 import os def split_long_image(image_path, output_dir, tile_size=1280, overlap=200): """ 将长卷图像切分为多个图块 :param image_path: 原图路径 :param output_dir: 输出目录 :param tile_size: 图块边长 :param overlap: 重叠像素数,保证跨块目标不丢失 """ os.makedirs(output_dir, exist_ok=True) img = cv2.imread(image_path) if img is None: print(f"无法读取图像: {image_path}") return h, w = img.shape[:2] step = tile_size - overlap tile_index = 0 for y in range(0, h, step): for x in range(0, w, step): x_end = min(x + tile_size, w) y_end = min(y + tile_size, h) # 边界块做填充,避免右侧和下侧出现窄条 tile = img[y:y_end, x:x_end] tile_h, tile_w = tile.shape[:2] if tile_h < tile_size or tile_w < tile_size: padded = np.zeros((tile_size, tile_size, 3), dtype=np.uint8) padded[:tile_h, :tile_w] = tile tile = padded # 文件名记录原点坐标:tile_y_x.jpg out_name = f"tile_{y:05d}_{x:05d}.jpg" cv2.imwrite(os.path.join(output_dir, out_name), tile) tile_index += 1 print(f"切块完成,共生成 {tile_index} 个图块")3.3 坐标映射回原图
如果模型在第 i 个图块检测到某个人物,边界框为(x_tl, y_tl, x_br, y_br),加上该图块在整图中的原点偏移量,就能还原回原图坐标:
def map_to_global(tile_x, tile_y, box, tile_size=1280, overlap=200): """ box: [x_tl, y_tl, x_br, y_br] 模型输出的像素坐标 tile_x, tile_y 来自图块文件名中的坐标 """ global_x1 = tile_x + box[0] global_y1 = tile_y + box[1] global_x2 = tile_x + box[2] global_y2 = tile_y + box[3] return [global_x1, global_y1, global_x2, global_y2]这一步看起来简单,却是后续统计、可视化正确性的基础。如果坐标映射出错,后面画出来的长卷人物分布图就会错位,所有的分组统计也就失去了意义。
4. 建立人物形态与行当标签体系
4.1 标签体系设计原则
做古画人物分析,不能只检测“是不是人”,还要记录“这个人是什么状态”。不同项目目标不同,标签体系设计也不一样。针对《仇英版清明上河图》,建议按三个维度打标:
- 人物密度维:单个人、群组、聚众场景。
- 姿态动作维:站立、行走、挑担、推车、骑行、对弈、跪拜、作揖、观望、劳动等。
- 行当身份维:商贩、轿夫、渔夫、工匠、书生、官员、僧侣、乐手、杂役、妇人、孩童等。
4.2 分类标签示例
person person_walking person_standing person_sitting person_carrying_pole person_pushing_cart person_riding_horse person_watching_show person_trading person_bowing person_rowing ...这里不建议只打“人”一个类别。虽然检测模型只需要输出person,但后续想要回答“明代苏州街头的常见行当是什么”,必须依赖更丰富的语义标签。所以实际操作中通常分层处理:
- 先用目标检测模型或人工框选,得到人物位置框。
- 再对每个框做 image classification 分类,判断姿态与动作。
- 结合周围场景,判断行当属性。
4.3 姿态判断的可视化思路
对于一张古画区域,可以截取检测到的单个人物框,然后保存为一个独立的小图,再批量给这些“人物小图”打上姿态分类标签。这样可以把标注任务拆成两个阶段,降低单次标注难度。
import cv2 def crop_person_boxes(raw_image_path, boxes, output_dir): """ boxes: 原图坐标列表,每个元素是 [x1, y1, x2, y2] """ img = cv2.imread(raw_image_path) os.makedirs(output_dir, exist_ok=True) for idx, box in enumerate(boxes): x1, y1, x2, y2 = [int(v) for v in box] # 扩展一点边界,保留衣物轮廓 pad = 10 x1 = max(0, x1 - pad) y1 = max(0, y1 - pad) x2 = min(img.shape[1], x2 + pad) y2 = min(img.shape[0], y2 + pad) person_crop = img[y1:y2, x1:x2] if person_crop.size == 0: continue out_path = os.path.join(output_dir, f"person_{idx:05d}.jpg") cv2.imwrite(out_path, person_crop) print(f"已裁剪 {len(boxes)} 个人物区域")这样的“检测 + 裁剪 + 再分类”流程在人物密集场景中非常实用。因为检测框可能不够精确,先裁出来让人工或小分类模型判断,会比直接在大图上标注容易很多。
5. 基于目标检测模型识别长卷人物
5.1 模型选择建议
对于这幅长卷中大量小尺寸人物,YOLOv8 或 RT-DETR 这类模型都有较好的效果。如果硬件资源有限,可以采用 YOLOv8n 这样的小模型,然后在自有标注数据上微调。不过这里需要明确:直接用预训练模型识别古画人物,效果通常不太理想,因为古画的绘画风格和真实照片差异很大。理想情况是:
- 先人工标注一部分人物区域,比如 500~1000 个框。
- 用标注数据微调模型。
- 再对全图做推理。
- 人工检查漏检和误检。
如果只是做快速浏览,也可以先用预训练模型跑一遍,把检测结果人工修正后当作基础标注。
5.2 基于 YOLOv8 的检测示例
from ultralytics import YOLO # 加载模型,这里假设已经经过微调 model = YOLO("runs/detect/train/weights/best.pt") results = model.predict( source="tiles", conf=0.25, save=False, imgsz=1280, device="cuda" )跑完后,需要把结果按照前面提到的坐标映射方式还原到原图。代码可以这样写:
import os all_boxes = [] for result in results: tile_path = result.path filename = os.path.basename(tile_path) # 从文件名解析原点坐标 # tile_y_x.jpg parts = filename.replace(".jpg", "").split("_") tile_y = int(parts[1]) tile_x = int(parts[2]) boxes = result.boxes.xyxy.cpu().numpy() for box in boxes: x1, y1, x2, y2 = box # 去掉 padding 区域影响 global_box = map_to_global(tile_x, tile_y, [x1, y1, x2, y2]) all_boxes.append(global_box)这里需要注意,如果切块时做了边界填充,那么落在填充区域的检测结果需要特殊处理,否则会产生靠近右边界和下边界的重复检测。一种简单做法是:如果检测框中心点落在 padding 区,就跳过。
5.3 重叠区去重
滑窗切块会带来大量重叠区域,同一个目标可能出现在多个相邻图块中,因此需要进行 NMS(非极大值抑制)或自定义去重。直接用 NMS 的简单方法:
import numpy as np def nms_boxes(boxes, iou_threshold=0.5): if len(boxes) == 0: return [] boxes = np.array(boxes).astype(float) x1 = boxes[:, 0] y1 = boxes[:, 1] x2 = boxes[:, 2] y2 = boxes[:, 3] areas = (x2 - x1) * (y2 - y1) order = areas.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) w = np.maximum(0.0, xx2 - xx1) h = np.maximum(0.0, yy2 - yy1) inter = w * h iou = inter / (areas[i] + areas[order[1:]] - inter) inds = np.where(iou <= iou_threshold)[0] order = order[inds + 1] return [boxes[int(i)] for i in keep]在原场景中,如果两个“人物”在画面里确实紧挨着,当前后景人物重合度较高时,NMS 可能会误删其中一个。因此阈值不能设得太低,建议在 0.4~0.6 之间根据实际效果调整。
6. 数据统计与可视化
6.1 把检测结果整理成 DataFrame
在分析和可视化之前,最好把所有检测框和标签整理成结构化表格。例如:
import pandas as pd columns = ["x1", "y1", "x2", "y2", "width", "height", "label"] data = [] for box, label in zip(all_boxes, all_labels): x1, y1, x2, y2 = box data.append([x1, y1, x2, y2, x2 - x1, y2 - y1, label]) df = pd.DataFrame(data, columns=columns) df["center_x"] = (df["x1"] + df["x2"]) / 2 df["center_y"] = (df["y1"] + df["y2"]) / 2 df.to_csv("output/person_stats.csv", index=False)有了这个表格,就可以回答很多问题。
6.2 绘制长卷人物分布热力图
《仇英版清明上河图》最有趣的特征是“哪里人多、哪里人少”,把人物中心点投射在长卷横向位置上,就能看出来画面节奏。我们以中心点 x 坐标作为横轴,统计不同区段的人物数量:
import matplotlib.pyplot as plt import numpy as np plt.figure(figsize=(20, 4)) plt.hist(df["center_x"], bins=200, color="#8B0000", alpha=0.8) plt.title("仇英清明上河图人物横向分布") plt.xlabel("长卷横向位置(像素)") plt.ylabel("人物数量") plt.show()6.3 行当构成统计
如果每个检测框已经标注了行当类型,可以使用 Pandas 做分组统计:
category_count = df["label"].value_counts() print(category_count) plt.figure(figsize=(12, 4)) category_count.plot(kind="bar", color="#2E8B57") plt.title("人物行当统计 Top 20") plt.ylabel("数量") plt.show()除了这些基础图,还可以继续做:
- 人物身高分布图。
- 不同场景区段的行当交叉统计。
- 人物朝向的极坐标图。
- 画面关键区域(桥、城门、戏台)的人群密度对比。
这些图可以帮助研究者更直观地理解画面结构,也为后续写论文或做策展提供数据支撑。
7. 标注工具与人工复核流程
7.1 推荐标注工具
人工标注古画人物,最常见的选择有两个:
- LabelImg:轻量级,适合快速画矩形框,支持 VOC 格式。
- X-AnyLabeling:支持交互式分割和自动标记,处理重叠目标效率更高。
对于长卷中大量小目标,建议配合“自动检测 + 人工修正”的工作流:先用模型或预标注系统生成一批候选框,再让标注人员删除误检、补充漏检。这样能极大降低工作量。
7.2 标注质量检查
标注质量直接决定模型能力和统计结论的可信度。建议至少做以下检查:
- 框是否贴合人物主体,有没有把背景大范围框进去。
- 是否存在重复框,特别是两个目标重叠的时候。
- 是否存在漏检,尤其要注意暗部、树荫下、建筑转角处的小人物。
- 类别标签是否统一,避免出现“商人”和“商贩”混用的情况。
这里也建议在标注规范里写清楚“边界不清晰时如何处理”。古画是写意风格,人物轮廓往往不是清晰轮廓线,标注员之间需要形成一致约定。
8. 常见问题与排查思路
在长卷人物的数字化分析中,新手最容易遇到下面几类问题,这里整理成了一个排查表格。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 切块后文件太多,磁盘空间不足 | 图块保存为无损 JPG,或步长太小导致重叠过多 | 提高步长压缩比,或使用 PNG 转 JPG,也可以直接不落盘,在内存中推理 |
| 模型识别出大量非人物目标 | 预训练模型没见过古画风格 | 人工标注部分数据后微调模型,或使用风格迁移数据增强 |
| 跨块目标被重复计数 | 重叠区域没有去重 | 合并结果后用 NMS 或按中心点去重 |
| 检测框坐标错位 | 未做坐标原点映射 | 检查文件名解析逻辑,确保 tile_y、tile_x 顺序正确 |
| 人物密集区域漏检严重 | 目标太小或相互遮挡 | 提高推理图像分辨率,或把大图块再细分,使用小目标增强策略 |
| 统计人数与视觉感受相差太大 | 漏检+误检同时存在 | 先做抽样人工审核,计算精确率和召回率,再决定阈值与模型调整方向 |
| 手工标注花费时间过长 | 标注工具使用不熟练,或规范不清晰 | 先标注少量样本统一标准,再用预标注辅助 |
9. 工程项目落地中的最佳实践
如果要把这张古画的数字化分析做成一个相对正式的研究项目,而不只是个人练手,有几个工程问题值得提前规划。
9.1 统一坐标系与切图规范
所有图块必须保持统一的命名规范和坐标记录方式。建议在图块文件名中直接编码原点坐标,而不是额外维护一份映射表。虽然映射表看起来更灵活,但工程里文件名丢失、排序错误都会造成坐标错乱。用tile_{y}_{x}.jpg的方式更容易排查问题。
9.2 训练集与测试集切分
如果最后要训练模型,不能把同一个人物的重叠块同时放进训练集和验证集。因为滑窗产生的相邻图块内容高度重叠,会让模型评估指标虚高。建议按照场景切分,不让同一场景区域同时出现在训练和验证中。
9.3 标注一致性
多个标注人员参与时,要考虑标签规范的细化程度。比如“推车”和“拉车”需要明确车轮相对人的位置;“作揖”和“拱手”在画面里很难区分,是否合并为一个类别,要提前定义。必要时可以用标注示例图来说明。
9.4 数字人文研究的可解释性
如果这篇文章的内容服务于学术研究,建议把检测和分类结果做成可回溯的版本。例如导出一张带标注框的低分辨率整图,快速核对某个统计数据背后对应的是画面中的哪些人物。这样能避免数据漂亮但无法经受仔细推敲的尴尬。
10. 下一步可以怎么做
仇英《清明上河图》之所以被视为一部“明代生活图像大全”,是因为它的细节不是个别亮点,而是成体系的信息密度。通过目标检测、姿态分类、空间统计和可视化,我们完全可以把它拆成一组可量化的文化数据:市井职业的组成比例、聚众场景的空间位置、人物动态的密集区间。
如果你对这个方向感兴趣,可以做三件事:
- 找一张高清古画长卷,先完成切块、预标注、人工修正的完整流程,跑通以后再扩展到更多画作。
- 试着用分类模型对每个人物区域做姿态识别,比较不同姿态类别在画面不同位置的分布变化。
- 如果对数字人文感兴趣,可以把画作中的空间关系和历史文献对应起来,形成跨模态的研究素材。
技术不是用来“解构”一幅画的美感,而是帮助我们看到肉眼容易忽略的群体性规律。人物虽多,放大之后各有各的姿势与营生,缩到统计视图里,又能呈现出明代市井的空间结构。这种“看画”的方式,对做文化遗产数字化的开发者来说,本身就是一个有趣的长期课题。希望这篇教程能帮你打开思路,跑通属于自己的古画分析流程。