ArcGIS 里一整个工程的面要素标注,导出成 shp 之后,丢进 YOLOv8 训练脚本里跑不通,这个场景我碰到过不止一次。问题不在于标注质量,而在于两套体系说的根本不是一种语言:ArcGIS 面要素标注记录的是地图坐标下的多边形几何,YOLOv8 的 TXT 标签要的是相对图像宽高归一化之后的纯数字序列。中间这段转换没人替你写,官方文档也不会告诉你坑在哪。这篇内容就是把这段转换完整拆开:从 ArcGIS 端面要素标注该守哪些规矩、坐标系怎么对齐、面要素转点的三种取法各自适合什么任务、大图切片之后坐标怎么偏移、一直到 TXT 落盘、画回原图做质检。读者对象是所有手里已经有一批 shp 面要素标注、但卡在格式转换这一步的人,不管你是遥感桥梁检测、建筑物提取、还是别的什么目标,只要源头是面要素、终点是 YOLOv8,流程是通用的。代码全部给到可直接复现的程度,参数计算过程也一步步写出来。
1. 先想明白:这一步转换到底难在哪
1.1 制图语言和训练语言之间的断层
在 ArcGIS 里画一个桥梁面要素,你脑子里想的是"这座桥的轮廓",落在数据里可能是十几个顶点串起来的一个闭合环,还可能是个带洞的多部件要素。这个几何对象挂在某个空间参考下,坐标值动辄是六位数、七位数(投影坐标系单位米),或者是小数点后好几位(地理坐标系单位度)。而 YOLOv8 读的 TXT 里,每一行都是一串 0 到 1 之间的浮点数,多一位少一位都对不上。
这个断层有三层。第一层是坐标单位:米和度都不是"像素",而模型要的是像素。第二层是取值范围:绝对坐标必须映射到图像内部的相对位置,也就是除以图宽图高做归一化。第三层是语义:一个面要素到底是转成一个外接框、一串多边形顶点、还是压缩成一个中心点,取决于你跑的是检测、分割还是关键点任务,选错了训练出来就是一堆没法解释的结果。
很多人卡住不是因为不会写代码,而是因为没意识到这三层是分开的。他们拿 shp 直接读出来,坐标除以一个"看起来像"的数就写 TXT,训练 loss 掉得很好看,推理出来框全跑到图外面去了。这种情况十有八九是第二层没做对,或者做对了但用错了参考宽度。
1.2 YOLOv8 的 TXT 到底接受哪几种形态
把话说清楚,YOLOv8 系列任务对应的标签格式是分家的,同一个labels/xxx.txt目录下,内容形态由任务决定。
| 任务类型 | 每行结构 | 数值含义 | 归一化 |
|---|---|---|---|
| 目标检测 | cls cx cy w h | 框中心点、宽高 | 全部除以图宽图高 |
| 实例分割 | cls x1 y1 x2 y2 ... | 多边形顶点序列 | 全部归一化 |
| 姿态/关键点 | cls cx cy w h x1 y1 v1 ... | 框 + 关键点 + 可见性 | 坐标归一化,v 为 0/1/2 |
| 旋转框 OBB | cls x1 y1 x2 y2 x3 y3 x4 y4 | 四角点 | 全部归一化 |
标题里说的"TXT 点格式",落到实际场景通常是两类需求:一类是把面要素的顶点序列直接导出成多边形点串,喂给分割任务;另一类是面要素太多太密、或者目标本身"有位置无所谓精确边界"(比如桥梁中心位置定位、杆状物点位),就只取一个代表点,走关键点那一套。少数人还会把面要素外接框算出来当检测标签用,这也算一种"点"的降维。
我的建议是先定任务再定格式。桥梁这种长条形目标,如果要做精细提取,走分割格式最合理;如果只是做"哪里有桥"的定位统计,取质心点或者外接框都够用。格式选错了,后面切图、增强、评估全得返工。
注意:YOLOv8 的姿态格式里每条标签只允许一套关键点结构,且关键点必须属于同一个目标实例。把一堆散点硬塞进一行当关键点,训练时会直接报维度不匹配。
1.3 三条转换路线的取舍
面要素转点格式,我实际用过的有三条路。
第一条是质心降维。用 shapely 的centroid或者representative_point取面内部的一个点,再归一化成一个点坐标。优点是数据量小、标签干净、切片之后基本不会出现"目标被切成两半"的问题;缺点是丢了几何信息,模型学不到边界,对面积、长宽比没概念。适合做目标计数、点位回归、粗定位。
第二条是外接框降维。minx, miny, maxx, maxy直接转cx, cy, w, h。这条最常用,兼容性最好,所有检测模型都吃。缺点是长条形的桥梁斜着放的时候,外接框会圈进一大片非目标区域,标签质量打折。判断标准很简单:如果你的目标长宽比普遍超过 5:1 且方向杂乱,外接框会很难看。
第三条是顶点序列全保留。把面的 exterior 环顶点按顺序导出,走分割格式。信息最全,但要求标注本身足够精细,顶点数不能爆炸(一般控制在 100 点以内),而且切片时要对多边形做真实的裁剪求交,不能简单按中心点判定。
三条路线的代码骨架区别不大,差别在几何处理和切片过滤策略上。下面我按"全流程"的方式展开,最后会把三种写法的差异点标出来。
2. 动手之前:ArcGIS 端该守的规矩
2.1 面要素标注的硬性规范
转换脚本写得再漂亮,源数据不规范也救不回来。这几条是我返工最多的点,提前说清楚。
单部件优先。ArcGIS 要素类允许一个要素由多个不相连的部分组成(MultiPart)。一座桥如果是主桥加引桥分开画的,很容易变成多部件。转换时如果不做拆解,取质心可能落到两个部分中间的水面上,取外接框会把中间几百米全圈进去。规范做法是在 ArcGIS 里用"多部件至单部件"工具拆开,或者在脚本里用explode处理。
不允许自相交和退化环。手绘标注难免有回折,形成自相交多边形,centroid计算会抛出拓扑异常。导出前跑一次几何修复,或者在读入后统一走buffer(0)做一次清洗。
洞(内环)要明确处理。桥梁面一般没有洞,但如果你标注的是"环形立交"或者"带空洞的建筑",内环会被exterior忽略掉。对分割任务来说这问题不大,对面积统计类任务要单独考虑。
要素不能在影像范围外。这条听起来废话,但实际做过的都懂:影像裁过边、标注是更早版本、或者工程坐标系和影像不匹配,都会导致部分要素落在影像外。转换时必须做边界裁剪,否则归一化出来是负数。
提示:在 ArcGIS 里先把要素类按"面积"字段排个序,最小的一批挨个目视检查一遍。小面积要素往往是误标或者画错闭合导致的,它们在切片后几乎全部会被过滤掉,白占训练位。
2.2 字段设计:类别 ID 怎么传下去
YOLO 的 TXT 第一列是类别索引,从 0 开始,且必须和data.yaml里的names顺序严格对应。ArcGIS 属性表里一般会有一个class或type字段存中文名,直接用它做映射是最稳的。
我习惯在导出前加一个整型字段,叫cls_id,用字段计算器批量填好。这样脚本读进来直接取整数字段,不用在 Python 里再做一次字符串匹配。中文类别名在某些编码环境下读出来是乱码,字符串匹配会静默失败,最后所有标签都变成 class 0,训练出来的模型只会认一个类,排查起来极其痛苦。
映射表建议单独存一份 CSV,脚本读它,人工核对它。类别一多,硬编码在代码里的字典迟早会漏。
| 原始类别名 | cls_id | 说明 |
|---|---|---|
| 桥梁 | 0 | 主线目标 |
| 高架 | 1 | 与桥梁形态接近,注意标注一致性 |
| 涵洞 | 2 | 面积普遍偏小,切片时易丢 |
| 背景 | — | 不写入标签,作为负样本保留 |
负样本这一行值得单独说。整幅影像里没有目标的位置,最终会生成一个空的 TXT 文件(0 字节)。这是合法的,YOLOv8 会把它当作"这张图没有目标"来处理。但如果切片策略不当,空标签文件占比过高(比如超过 60%),训练效率会明显下降。这个比例在切完片之后统计一下,心里要有数。
2.3 坐标系:整个流程最容易翻车的地方
归一化需要的是图像像素坐标,不是地图坐标。这两者之间隔着影像自己的仿射变换参数(geotransform),也就是六个数字:左上角 x、像素宽、行旋转、左上角 y、列旋转、像素高(通常为负)。
转换的两种典型情况:
情况一,影像带完整空间参考,且标注是在这个参考下画的。这是最理想的情况。用 rasterio 打开影像,拿到transform,然后用~transform * (x, y)反算像素行列号,或者直接rasterio.transform.rowcol(transform, x, y)。这一步是精确的,不需要任何人工对齐。
情况二,影像没有空间参考,或者标注是在导出的 JPG 上手工画的。这时候标注坐标本身就是像素坐标(或者某个比例尺下的坐标),必须问清楚原始比例。我见过最坑的一次,是标注在 ArcGIS 里对着 1:2000 的显示比例画的,坐标值是"地图单位",而影像实际是 0.5 米分辨率的裁剪图,两者差了 4000 倍。这种情况下唯一的解法是找两三个明显地物点做人工配准,求出缩放和平移。
判断自己处于哪种情况,有个土办法:随便取一个要素,把它的坐标打印出来,看看数值量级。六位数以上基本是投影坐标(米),个位数到三位数带小数可能是经纬度,如果数值范围恰好和影像宽高对得上,那就是像素坐标。这一步别省,量级看一眼能省掉两天调试。
注意:地理坐标系(经纬度)下的面要素,不能直接按度数做面积过滤,因为经纬度的"度"在不同纬度对应的实际距离完全不同。要过滤面积,先投影到等积坐标系,或者用测地线面积计算。
3. 核心算法拆解:面到点的坐标变换
3.1 归一化公式与参数计算过程
不管走哪条路线,最后一步都是归一化。公式本身简单,但每个量的定义必须钉死。
设影像宽W、高H,某个像素坐标为(px, py),其中px是列号(从左往右,0 到 W-1),py是行号(从上往下,0 到 H-1)。归一化:
nx = px / W ny = py / H这里有个细节:分母用W还是W-1。YOLO 官方实现用的是W(直接除宽),不是W-1。像素坐标本身按"像素中心在 (col+0.5)"理解更严谨,但实际标注工具和官方转换脚本都按整数像素除以宽高处理,误差在千分之一像素级,可以忽略。保持一致比追求精确更重要,因为你后面质检回放用的也是同一套公式。
外接框归一化:
cx = (minx + maxx) / 2 / W cy = (miny + maxy) / 2 / H w = (maxx - minx) / W h = (maxy - miny) / H主轴顺序是(x, y)对应(列, 行),也就是(经度方向, 纬度方向)。这个顺序写反了是新手最常见的错误,症状是训练能跑、loss 能降、但框全是转置的。
举个具体数字走一遍。假设影像 8192 × 6144 像素,某个桥梁面的外接框像素范围是minx=1024, miny=2048, maxx=2048, maxy=2560:
cx = (1024+2048)/2/8192 = 1536/8192 = 0.1875cy = (2048+2560)/2/6144 = 2304/6144 = 0.375w = (2048-1024)/8192 = 1024/8192 = 0.125h = (2560-2048)/6144 = 512/6144 = 0.0833
写进 TXT 就是0 0.187500 0.375000 0.125000 0.083333。保留 6 位小数是通行做法,足够表达 8192 宽度下的亚像素精度(1/8192 ≈ 0.000122,6 位小数够)。
3.2 三种取点策略的具体几何处理
质心策略。shapely 的polygon.centroid返回的是面积质心。对凸多边形没问题,对 L 形、U 形的桥梁,质心可能落在面外。要保证点一定在面内,用polygon.representative_point(),它保证返回面内的一个点,但不保证在视觉中心。我一般两个都算,比较一下距离,偏差大的手工看一眼。
对多部件要素,务必先explode或者取max(geoms, key=area)取最大部件,否则质心会飘到中间。
外接框策略。polygon.bounds给出(minx, miny, maxx, maxy)。这里注意是(xmin, ymin, xmax, ymax)的顺序,别和(x1, y1, x2, y2)混。对倾斜的长条形目标,外接框会显著大于实际目标,可以加一步判断:如果polygon.area / bbox_area < 0.5,说明目标方向和框轴差异大,这时候要么保留(检测任务能忍),要么改用最小外接矩形(minimum_rotated_rectangle)走 OBB 分支。
顶点序列策略。取polygon.exterior.coords,去掉最后一个重复的闭合点。顶点数超过阈值(比如 150)时做简化,用polygon.simplify(容差),容差取"影像短边 / 500"这个量级比较合适——1024 短边对应约 2 像素容差,肉眼看不出来但能砍掉一半顶点。
这里要提醒一句:分割格式要求每行的点数是可变的,不需要补齐。但有些自己写的校验脚本会假设固定长度,导致报错。写校验时按len(line) >= 7 且 (len(line)-1) % 2 == 0判断。
3.3 切片场景下的坐标偏移计算
这是真正拉开工作量差距的部分。遥感影像动辄上万像素,直接送进网络不现实,必须切片。切完片之后,标签坐标要从"全图坐标"换算到"切片内坐标"。
设切片窗口左上角在全图中的像素坐标为(x0, y0),窗口宽高tw, th。切片内归一化:
nx = (px - x0) / tw ny = (py - y0) / th看起来就是减一下,但过滤策略才是关键。
检测任务的过滤:我用的规则是"目标框与切片窗口的交集面积占原框面积的比例 ≥ 0.4 才保留,保留时用裁剪后的框"。0.4 这个阈值是试出来的:太低会引入大量只剩一条边的残缺目标,模型学不到完整形态;太高会丢掉位于切片边界的目标,如果重叠设置得不够,会漏检。
分割任务的过滤:必须做真实的多边形求交。用 shapely 的polygon.intersection(Window(x0, y0, x0+tw, y0+th).box()),求交后可能得到 MultiPolygon,取最大的一块。求交后再算交集面积占比,同样按 0.4 左右判断。
关键点任务的过滤:最简单,判断点是否落在窗口内即可,x0 <= px < x0+tw。落在边界外直接丢。
重叠参数怎么定?给个计算过程。假设原图10000 × 8000,切片1024 × 1024,重叠256,则步长stride = 1024 - 256 = 768:
- 列数
ncols = ceil((10000 - 1024) / 768) + 1 = ceil(11.6875) + 1 = 12 + 1 = 13 - 行数
nrows = ceil((8000 - 1024) / 768) + 1 = ceil(9.083) + 1 = 10 + 1 = 11 - 总切片数
13 × 11 = 143
最后一片的起点是12 × 768 = 9216,9216 + 1024 = 10240 > 10000,超出 240 像素。所以窗口生成时必须做min(x0, W - tw)的钳位,否则读图会出错。钳位之后最后一片和倒数第二片的重叠会变大,这没关系,只是冗余多一点。
提示:切片尺寸选择上,1024 是个比较稳妥的默认值。想训小目标就降到 640 并加大重叠到 320,代价是切片数量翻好几倍,磁盘和标注文件数量也跟着涨。
4. 完整实操:从 shp 到 TXT 跑通一遍
4.1 环境与依赖
pip install geopandas shapely rasterio numpy pillow matplotlib pandasgeopandas 负责读 shp,shapely 做几何运算,rasterio 拿影像的仿射变换和宽高。版本上 geopandas 0.14 以上、shapely 2.0 以上配合比较顺,shapely 2.0 的几何运算比 1.x 快一个量级,几万个面要素的转换时间能从十几分钟压到一分钟以内。
4.2 读入、清洗与坐标统一
import geopandas as gpd import rasterio from shapely.geometry import box import numpy as np SHP = "bridge_labels.shp" IMG = "scene_01.tif" OUT_DIR = "labels" gdf = gpd.read_file(SHP, encoding="utf-8") # 1) 几何修复与多部件拆分 gdf = gdf[gdf.geometry.notnull()].copy() gdf["geometry"] = gdf.geometry.buffer(0) gdf = gdf.explode(index_parts=False).reset_index(drop=True) # 2) 影像参考信息 with rasterio.open(IMG) as src: W, H = src.width, src.height T = src.transform img_crs = src.crs # 3) 坐标系对齐:把标注投影到影像坐标系 if gdf.crs != img_crs: gdf = gdf.to_crs(img_crs) # 4) 面要素 -> 像素坐标多边形 def to_pixel_polygon(geom, transform): from rasterio.features import geometry_mask from shapely.ops import transform as shp_transform return shp_transform(lambda x, y, z=None: rasterio.transform.rowcol(transform, x, y)[::-1], geom) gdf["pix_geom"] = gdf.geometry.apply(lambda g: to_pixel_polygon(g, T))这里有个容易忽略的点:rowcol返回的是(row, col),而 shapely 的坐标是(x, y),所以必须[::-1]翻一下。翻漏了整批数据都会转置。
另外buffer(0)对几乎所有自相交几何都能修复,但它会改变极小面积图形的拓扑,个别退化图形会变成空几何。加一步gdf = gdf[~gdf["pix_geom"].is_empty]兜底。
4.3 切片生成与标签同步写盘
import os from pathlib import Path from PIL import Image Path(OUT_DIR).mkdir(parents=True, exist_ok=True) Path("images").mkdir(parents=True, exist_ok=True) TILE = 1024 OVERLAP = 256 STRIDE = TILE - OVERLAP MIN_RATIO = 0.4 CLS_MAP = {"桥梁": 0, "高架": 1, "涵洞": 2} def gen_windows(W, H, tile, stride): xs = list(range(0, max(W - tile, 0) + 1, stride)) ys = list(range(0, max(H - tile, 0) + 1, stride)) xs = sorted(set([min(x, max(W - tile, 0)) for x in xs] + [max(W - tile, 0)])) ys = sorted(set([min(y, max(H - tile, 0)) for y in ys] + [max(H - tile, 0)])) return [(x, y, min(tile, W - x), min(tile, H - y)) for x in xs for y in ys] src_img = Image.open(IMG).convert("RGB") total_boxes = 0 empty_tiles = 0 tile_count = 0 for x0, y0, tw, th in gen_windows(W, H, TILE, STRIDE): tile_count += 1 win = box(x0, y0, x0 + tw, y0 + th) lines = [] sub = gdf[gdf["pix_geom"].intersects(win)] for _, row in sub.iterrows(): cls_id = CLS_MAP.get(str(row["type"]).strip(), None) if cls_id is None: continue inter = row["pix_geom"].intersection(win) if inter.is_empty: continue ratio = inter.area / row["pix_geom"].area if ratio < MIN_RATIO: continue minx, miny, maxx, maxy = inter.bounds cx = (minx + maxx) / 2 - x0 cy = (miny + maxy) / 2 - y0 bw = maxx - minx bh = maxy - miny # 越界钳位 cx = min(max(cx, 0), tw) cy = min(max(cy, 0), th) bw = min(bw, tw) bh = min(bh, th) if bw < 4 or bh < 4: continue lines.append(f"{cls_id} {cx/tw:.6f} {cy/th:.6f} {bw/tw:.6f} {bh/th:.6f}") total_boxes += 1 name = f"tile_{x0}_{y0}" src_img.crop((x0, y0, x0 + tw, y0 + th)).save(f"images/{name}.jpg", quality=92) with open(f"{OUT_DIR}/{name}.txt", "w") as f: f.write("\n".join(lines) + ("\n" if lines else "")) if not lines: empty_tiles += 1 print(f"切片 {tile_count},有效框 {total_boxes},空标签片 {empty_tiles},空片占比 {empty_tiles/tile_count:.2%}")跑完之后那几个统计数字必须看一眼。空片占比超过 60% 说明重叠没调好或者影像里有大片空白区域,要么砍掉这些区域,要么调整切片起点;有效框数量少得离谱,回头查坐标系是不是没统一。
4.4 分割格式与关键点格式的写法差异
同一套骨架,把写行的那一段换掉就行。
分割格式:
coords = list(inter.exterior.coords)[:-1] # 去闭合点 if len(coords) > 150: inter = inter.simplify(max(tw, th) / 500) coords = list(inter.exterior.coords)[:-1] pts = [] for px, py in coords: lx = min(max(px - x0, 0), tw) / tw ly = min(max(py - y0, 0), th) / th pts.extend([f"{lx:.6f}", f"{ly:.6f}"]) lines.append(f"{cls_id} " + " ".join(pts))关键点格式需要外接框加点的可见性:
r = row["pix_geom"].representative_point() kx = (r.x - x0) / tw ky = (r.y - y0) / th if not (0 <= kx <= 1 and 0 <= ky <= 1): continue lines.append(f"{cls_id} {cx/tw:.6f} {cy/th:.6f} {bw/tw:.6f} {bh/th:.6f} " f"{kx:.6f} {ky:.6f} 2")可见性取值:0 表示未标注,1 表示标注但不可见(被遮挡),2 表示可见。自己做数据一般全部填 2,除非确实有被遮挡的桥梁需要区分。
5. 转换完不算完:把 TXT 画回图上验一遍
5.1 可视化回放是最快的体检手段
这一步千万别跳。我的习惯是每次转换完,随机抽 20 张画回原图看,比看任何统计数字都直观。
import matplotlib.pyplot as plt import matplotlib.patches as patches def draw_tile(img_path, txt_path): img = Image.open(img_path) w, h = img.size fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(img) if os.path.exists(txt_path): for line in open(txt_path): v = line.split() if len(v) < 5: continue cls, cx, cy, bw, bh = int(v[0]), *map(float, v[1:5]) ax.add_patch(patches.Rectangle( ((cx - bw/2) * w, (cy - bh/2) * h), bw * w, bh * h, fill=False, edgecolor="red", linewidth=1.5)) ax.text(cx*w, cy*h, str(cls), color="yellow", fontsize=8) ax.set_axis_off() plt.show()看一眼就能判断的东西:框是不是整体偏移了(坐标系问题)、框是不是全部转置了(x/y 顺序问题)、框是不是比实际目标大一圈(面要素本身画大了,或者用了外接框而目标很斜)、有没有出现细长到不合理的框(顶点数或求交出问题)。
5.2 数值级校验指标
肉眼过得去还不够,再跑几个数字。
| 校验项 | 判定方法 | 异常含义 |
|---|---|---|
| 坐标范围 | 所有值落在 [0, 1] | 越界说明钳位没做或归一化分母错 |
| 框宽高下限 | w*W >= 4且h*H >= 4 | 过小的是噪声标注 |
| 框面积占比 | 单框面积 < 全图 50% | 超过说明有要素横跨整图未被裁 |
| 类别分布 | 统计各类 count | 严重偏斜要复查映射 |
| 标签文件数 | 等于切片数 | 对不上说明写盘有跳漏 |
| 平均框数/片 | 与人工预估对比 | 差一个量级基本都是坐标问题 |
框面积占比这一项特别有用。我遇到过一次,某类目标全部是"整图框",原因是 shp 里有一个覆盖全区的图幅边界要素被误当成目标类导出了。肉眼抽检 20 张里如果没抽到那张就发现不了,统计一跑立刻暴露。
6. 踩过的坑都在这里:故障排查实录
6.1 坐标整体偏移:九成是参考系没对齐
场景描述:TXT 画回图像,框的形状和大小都对,就是整体平移了一个固定距离。这种情况最有可能是标注和影像的坐标原点不一致。
排查顺序:先比较影像左上角的地图坐标和标注要素的最小坐标量级是否接近。如果影像的transform左上角是(500000, 4000000)而标注坐标都在(0, 3000)这种小数值,说明标注是相对某个裁剪框的局部坐标,必须把裁剪时的偏移加回去。这个偏移量通常能在裁剪日志或者工程文件里找到。
还有一种更隐蔽的:影像本身是带旋转的(少数航摄影像是这样),transform里的 b 和 d 不为 0。这种必须用完整的仿射反变换,不能只做减法和除法,rowcol内部已经处理了,如果你自己手写(x - x0) / res就会偏。
6.2 空标签文件被误删
有人觉得 0 字节的 TXT 是垃圾,写成if not lines: continue直接不写文件。这是错的。YOLOv8 训练时图片和标签是配对查找的,图片存在但标签缺失会被当作"标签文件不存在"处理,某些版本会直接跳过这张图,某些版本会报错。正确做法是写一个空文件,明确表达"这张图没有目标"。
反过来的错误是:为了省事,把没有目标的切片图片直接不生成。这也不是不行,但前提是你确定负样本比例已经够高,不再需要背景样本。遥感场景里负样本是很宝贵的,纯背景切片能有效压制误检。
6.3 类别 ID 全部变成 0
症状:训练日志里只有一个类别,或者data.yaml加载时报警告。
原因通常有两个。一是映射字典的键和 shp 属性值对不上——属性值里可能带空格、带全角字符、或者编码读成乱码。用gdf["type"].value_counts()打印一下真实取值,照着写映射。二是直接用enumerate(gdf["type"].unique())自动生成 ID,但unique()的顺序在不同数据、不同 pandas 版本下不确定,第一次跑出来 0 是桥梁,第二次可能变成 0 是涵洞。类别映射必须显式写死并落盘成文件。
6.4 长条目标横跨多片导致大量残缺样本
桥梁这种目标,一座可能横跨两三张切片。按 0.4 阈值过滤后,中间片保留完整形态,两端的片保留残缺片段。如果重叠设得不够,可能两端的片刚好被过滤掉,中间那片又切不到全貌,最终这座桥在任何一片里都不完整。
解法是把重叠加大。经验公式:OVERLAP ≥ 目标最大长度 × 0.5。如果桥最长 800 像素,重叠就不能低于 400,切片就得设成 1024 配 400 重叠,步长 624,切片数大约翻倍。
6.5 常见问题速查表
| 现象 | 最可能原因 | 处理办法 |
|---|---|---|
| 框全部转置 90 度 | x/y 顺序写反 | 检查[::-1]和(col, row)顺序 |
| 框整体缩小到一角 | 归一化分母用了切片尺寸但坐标是全图坐标 | 先减偏移再除 |
| 框整体放大超出图 | 坐标减了偏移但除以的是原图宽 | 分母统一用切片宽高 |
| 所有框都是整图大小 | 混入了图幅边界类要素 | 按面积占比过滤 |
| 部分目标消失 | 切片时被交集过滤 | 降低阈值或加大重叠 |
| txt 数量少于图片 | 写了continue跳过空标签 | 空标签也写空文件 |
| 训练时报标签越界 | float 精度导致 1.000001 | 写盘前clip(0, 1) |
| 分割标签死循环报错 | 顶点数 < 3 | 求交后判断len(coords) >= 3 |
| 中文路径读不出图 | 编码问题 | 切片输出统一用 ASCII 命名 |
| 训练 loss 不降 | 类别 ID 全 0 或标签与图错配 | 抽查 20 张可视化 |
提示:把转换脚本的中间产物(像素多边形、切片窗口、过滤前后计数)都落盘成 GeoJSON 或者 parquet,出了问题能直接回到某一步核对,比重新跑一遍快得多。
6.6 几个不太起眼但真会咬人的细节
图片命名不要带中文和空格。切片命名我用tile_{x0}_{y0},纯 ASCII 加下划线。带中文的路径在某些数据加载的 worker 进程里会静默失败,表现为训练时 loss 正常但验证集指标完全不动,排查方向会全跑偏。
JPEG 质量别设太低。切片存 JPG 时质量给 92 以上。桥梁一般是细长结构,压缩伪影会在边缘产生毛刺,模型容易把这些毛刺当成纹理特征学进去。存储允许的话直接存 PNG,遥感场景下用 PNG 更稳。
归一化之后要 clip。理论上交集裁剪保证了坐标在 [0, tw] 内,但浮点运算偶尔会给出1.0000001。YOLOv8 的标签加载会做校验,越界直接抛异常。写盘前统一min(max(v, 0.0), 1.0),一行代码省掉一次半夜报错。
训练配置里的 imgsz 要和切片尺寸对齐。切片是 1024,训练imgsz设 640,YOLOv8 会把图缩放到 640,标签是归一化的所以自动跟着缩放,逻辑上没问题。但缩放到 640 之后,原本 8 像素宽的小桥只剩 5 像素,检测难度陡增。要么切片直接切 640,要么imgsz设成 1024。两者混着来是最容易出事的地方。
数据集划分要按空间区块切,不能随机切。这是遥感场景的硬要求。相邻切片重叠 256 像素,如果随机划分,同一座桥的重叠部分可能一半在训练集一半在验证集,验证指标虚高。做法是按切片左上角坐标做分块,比如(x0 // 2048 + y0 // 2048) % 10 < 7划进训练集,剩下进验证集,保证空间上不重叠。
一套流程跑下来,从 ArcGIS 面要素到 YOLOv8 能直接读的 TXT,快的话半天,慢的话两三天,取决于源数据的规范程度。我个人在实操中的体会是,真正花时间的从来不是写转换代码,而是回头去修源标注。所以如果重来一次,我会在标注阶段就把规则钉死:单部件、不自交、类别字段用整型、影像必须有且只有一个空间参考、面积过小的要素当场删掉。这五条守住了,转换脚本基本跑一次就过。另一个小技巧是:先拿三五张影像、几十个要素做一个小样本跑通全流程,把可视化回放看仔细了,再上全量。全量跑完发现坐标错,重来一遍的成本是几小时甚至几天,小样本验证的成本是十分钟。