做目标检测数据集的时候最磨人的往往不是模型不收敛而是图不够、标不准。尤其做工业设备识别、机械零部件检测这类项目样本得自己攒布景、走位、拍照、标框一套下来半天就没了最后还未必对齐。前阵子我干脆在 Unity 里搭了一条自动化采集流水线——让相机挂在 Cinemachine 的轨道上匀速巡航每走一段就停下来拍一张机器设备的图片同时把画面中所有目标的 boxes 坐标按标注格式一起吐出去。一个场景跑完能出几千张带标注的图坐标是程序算的不存在手抖标歪这回事。这套东西的价值在于可复现。人工拍照的光照、角度、距离每次都不同模型学到的东西很杂而轨道采集可以精确控制每一帧的相机位置和朝向同一批次数据的分布高度一致训练时 loss 曲线看着都舒服。它适合几类人做工业视觉质检的工程师、做数字孪生场景需要产出合成数据的人、以及想入门 Unity 相机系统又不想啃文档的新手。下面我把这套方案从设计思路到代码落地完整拆一遍踩过的坑一并说清楚。1. 项目整体设计与方案拆解1.1 需求拆解三件事必须同时发生这个标题看起来只是让相机沿轨道运动但真正做起来它是三件强耦合的事相机轨迹运动、图片渲染落盘、boxes 数据计算输出。三者必须严格同步——图片拍的是哪一帧绑定的标注就必须是哪一帧的相机状态。只要中间有一帧的相机位置没刷新或者数据算的是上一帧的视锥体标注框就会整体偏移这种错误肉眼很难发现最后训练出来的模型定位精度会莫名其妙地差一截。所以我在架构上定了两条铁律。第一相机位置必须由程序显式设置不依赖自动巡航的插值因为自动巡航是跟时间走的一旦掉帧就不可控。第二图片渲染和 box 计算必须共享同一个 Camera 对象的同一帧状态绝不能一个用 Game 视图相机、另一个用独立相机两者的 aspect 和 fov 只要有一点差异换算结果就会错。明确这两点之后剩下的就是纯工程问题了用 Cinemachine 管轨迹用 RenderTexture 管渲染用八点投影法管标注。方案整体不复杂但每一环都有细节。1.2 为什么用 Cinemachine 而不是自己写相机脚本自己写相机脚本沿轨道跑代码量其实不大几十行Vector3.Lerp就能动起来。但我还是选了 Cinemachine理由有三条。第一是路径的数学质量。Unity 的Spline组件UnityEngine.Splines包内部做了弧长参数化意味着你把位置参数从 0 推到 1相机走过的实际距离是均匀的。手写贝塞尔曲线很容易出现控制点密集处走得慢、稀疏处冲得飞快的问题采集出来的图片在轨道两端特别稀中间特别密数据集分布就歪了。第二是朝向控制的成熟度。Cinemachine 的LookAt配合阻尼、Aim配合Composer可以在相机沿轨道移动时自动把视线锁在目标设备上中间还有软区、死区、阻尼这些可调参数。手写的话你得自己处理万向锁、平滑插值、急转时的镜头抖动工作量大好几倍。第三是可调试性。Cinemachine 在运行期能在 Scene 视图里画出轨道线、目标点、视锥示意调参的时候非常直观。我经常一边跑一边拖着CameraPosition滑块手动看效果确认视角没穿模再开始批量跑。注意Cinemachine 是相机行为管理器它的输出是写到Camera.transform上的。这意味着你可以随时在它更新之后覆写相机的位置做微调但一定要在它更新之后否则会被下一帧覆盖掉。1.3 轨道形态的选择Spline、圆形还是离散点位轨道形态取决于被采集目标的分布。我总结了三类场景的选法。如果目标是一台大型设备比如机床、机械臂、整条产线目标基本固定不动那就用环绕式闭合轨道绕目标画一个圆或者椭圆相机贴着轨道走一圈。这种轨道采集的数据覆盖了目标的各个侧面对三维形变鲁棒性最好。如果目标是多个分散的物件比如一个托盘上随机摆放的零件那就用蛇形往复轨道相机在物件上方来回扫。这种轨道要注意拐弯处的角速度速度快了容易拍到模糊或者朝向突变建议在拐点设置停靠点单独采集。如果目标本身就是沿直线排列的比如传送带上的工件那用直线轨道最省事相机跟着直线走每个工件经过时拍一张逻辑上还能和产线的节拍对齐。选完形态之后轨道点位的密度也有讲究。经验值是轨道总长度除以采集步长得到的帧数控制在 300 到 1500 之间比较合适。帧数太少覆盖不全太多相邻帧的差异小于 5 个像素对训练几乎是冗余的白白占硬盘。2. 环境准备与场景侧的准备2.1 Unity 版本与 Cinemachine 包版本怎么选这块必须先说清楚因为 Cinemachine 在 3.0 版本做了一次大改API 名字全变了网上的老教程照抄会直接报错。Cinemachine 2.x 时代虚拟相机上挂的是CinemachineTrackedDolly路径组件是CinemachinePath或CinemachineSmoothPath路径位置参数m_PathPosition是 0 到 1 归一化的。Cinemachine 3.x 之后改成了CinemachineSplineDolly路径直接用UnityEngine.Splines包的SplineContainer位置参数CameraPosition变成了以米为单位的实际距离。我的建议是新项目直接上Unity 2022 LTS 或 Unity 6 Cinemachine 3.x因为 Splines 包是官方主推方向社区资源也在往这边迁移位置参数用米比用归一化直观太多。老项目如果已经深度绑定了 2.x 的 dolly 逻辑那就别急着升改动量不小。包依赖上Cinemachine 3.x 需要同时装com.unity.cinemachine和com.unity.splines两个包。装完之后在 Hierarchy 里右键就能看到Cinemachine Spline Cinemachine Spline Cart之类的模板不过我不建议用模板直接用组件拼更可控。另外提醒一句如果项目要发布到 WebGLCinemachine 本身没问题但要注意 WebGL 下ReadPixels的行为和编辑器里不一样这块我在第 7 节细说。2.2 场景与目标的组织标签、层级与包围盒来源标注数据的准确性一半取决于场景组织得干不干净。我踩过最深的坑就是场景里放了一堆装饰性物体采集的时候它们的包围盒也被算进去了标注文件里全是乱七八糟的框。正确的做法是给所有需要被标注的目标单独打一个 Layer比如Capturable然后在采集脚本里只遍历这一层的 Renderer。父节点统一挂在一个空的TargetRoot下面用GetComponentsInChildrenRenderer()一次性拿到所有渲染器结构清晰后续要加目标也只需要往这个根节点下面拖。另一点是包围盒的来源。Unity 里能拿包围盒的地方有好几处Renderer.bounds是世界空间的轴对齐包围盒AABBMesh.bounds是模型局部空间的Collider.bounds是碰撞体的。做标注用Renderer.bounds最合适因为它反映的是实际渲染范围和你在画面里看到的轮廓基本能对上。但要小心两个特例。一是SkinnedMeshRenderer它的bounds在播放动画时可能不跟随骨骼实时更新需要用localBounds重算或者每帧手动刷新。二是带粒子系统或者 Trail 的物体它们的渲染范围是动态的Renderer.bounds给的是一个很大的保守值不适合做精确标注这类物体建议直接不纳入采集。2.3 采集相机与显示相机的分工这里有个设计选择是用 Game 视图里的主相机直接截屏还是单独开一个采集相机渲染到 RenderTexture我选后者理由是分辨率解耦。主相机的分辨率受 Game 视图窗口大小影响你在编辑器里拖一下窗口输出图片的宽高就变了标注归一化坐标跟着变数据集就废了。用一个独立相机输出到固定尺寸的 RenderTexture无论编辑器窗口怎么变输出的图永远是 1280×720稳定可控。具体配置是主相机保留CinemachineBrain负责显示供你观察采集相机是一个独立的Camera组件关掉Audio Listener设置targetTexture指向一张固定尺寸的 RenderTexturefieldOfView、nearClipPlane、farClipPlane、aspect全部手动锁定不走自动计算。关键的一点采集相机的 transform 必须每一帧同步成主相机的 transform。最省事的写法是在采集脚本里直接captureCam.transform.SetPositionAndRotation(mainCam.transform.position, mainCam.transform.rotation)同时把fieldOfView也一起同步过来。这样两边的画面完全一致你在 Game 视图里看到的框和输出图里的框就能对上。3. 让相机沿轨道跑起来3.1 CinemachineSplineDolly 的核心参数Cinemachine 3.x 下的轨道巡航核心是虚拟相机上的CinemachineSplineDolly组件。它的关键参数有这么几个我一个个说。Spline字段指向场景里的SplineContainer。CameraPosition是当前在轨道上的位置单位是米从轨道起点开始算。PositionUnits可以选Distance米、Normalized0 到 1、Knot按控制点索引做匀速巡航一定要用Distance这是弧长参数化的前提。AutoDolly里的Enabled如果打开它会在相机的前方按轨道自动找最近点用来做相机跟随目标的模式但在我们这种程序化采集场景里必须关掉自动巡航位置完全由脚本推。还有Damping这一组包括位置和朝向的阻尼。阻尼是为了让手动拖滑块时镜头有惯性但程序化采集时如果开着阻尼你设置的位置和实际到位的角度会有一帧的滞后导致标注偏移。所以批量采集前我一般把PositionDamping和AngularDamping全设为零。轨道本身建议用SplineContainer里的Knot模式搭建控制点手动放然后用Tangent Mode Auto让 Unity 自动算切线出来的曲线比较平滑。控制点数量不用太多一个环绕轨道 8 到 12 个点足够太多反而容易在局部产生曲率突变。3.2 Cinemachine 2.x 的 TrackedDolly 写法老项目兼容如果是老项目路径和 dolly 的写法是这样的先建一个空物体挂CinemachineSmoothPath组件在它的Waypoints列表里手动加控制点每个点可以设置Roll侧倾。然后虚拟相机上挂CinemachineTrackedDolly把Path指向刚才那个路径对象PathPosition设成 0 到 1 之间的值PositionUnits选Normalized或者Distance。2.x 的路径对象有一个PathLength属性返回轨道的实际总长度做步长计算的时候直接用这个值。另外CinemachinePathBase有个EvaluatePositionAtUnit方法可以传入归一化参数反查世界坐标做调试很方便。两个版本的共同点是位置参数都是从起点开始推进区别只是单位不同。所以在采集脚本里我习惯统一用累计米数作为循环变量2.x 里再手动除以PathLength转成归一化3.x 里直接赋值。这样上层逻辑不依赖版本升级的时候只改一行。3.3 朝向控制LookAt、Aim 与阻尼相机在轨道上跑朝向有几种策略得按场景选。最常用的是把虚拟相机的LookAt指向目标设备的一个 Transform。这样无论相机走到轨道的哪个位置镜头永远对着设备的中心。但纯 LookAt 有个问题转折点附近的朝向变化会很剧烈镜头会有甩的感觉采集出来的图在角度上跳变。解决办法是加一个CinemachineRotationComposer2.x 叫CinemachineComposer在LookAt的基础上加一块软区。软区的意思是只要目标还在画面中心这块区域内相机就不动目标偏离出软区相机才开始跟随。把软区设成画面宽高的 30% 左右相机在轨道上的朝向就会变得很稳只在必要时才修正。另一种策略是固定朝向 轨道位移也就是相机的旋转完全由轨道控制点的切线方向决定不做 LookAt。这种适合沿直线扫描的产线场景拍出来的图永远正对工件姿态一致性好。我的经验是环绕轨道用 LookAt 软区直线轨道用固定朝向混合场景就分段切换。采集脚本里可以准备多个虚拟相机不同轨道段激活不同的那个切换时注意清一下PreviousStateIsValid标志避免阻尼残留。3.4 速度换算与停靠点计算批量采集不是让相机一直动而是走一段、停一下、拍一张。所以需要把轨道长度换算成采集点序列。假设轨道总长L米我想要总共N张图那么相邻两帧的间隔step L / N。反过来如果我先确定步长step比如 0.3 米那N ceil(L / step)。这两个方向都可以取决于你是想控制图片总数还是控制空间密度。我个人的偏好是按空间密度来定也就是固定步长。因为目标设备的尺寸是固定的步长 0.3 米意味着相机每移动 0.3 米就拍一张这样无论轨道多长对目标的空间采样密度都是一致的训练数据的角度分布均匀。而按总数定的话轨道长了步长就大采样变稀不合适。具体的循环长这样用一个浮点变量d从 0 循环到L每次递增step把d赋给 dolly 的CameraPosition等待渲染完成截图算 boxes写盘。整个循环跑在协程里这样每帧可以yield一次不会把编辑器卡死。一个坑不要在循环里用for加浮点累加因为浮点误差累积到后面会让实际步长偏移。用整数索引乘步长更稳比如float d i * step;这样误差不累积。4. boxes 数据的计算与输出4.1 Renderer.bounds 到底给了我们什么Renderer.bounds返回的是世界空间的轴对齐包围盒AABB它有两个属性直接可用center是世界坐标中心点extents是三个轴的半长。8 个角点就是center ± extents的各种正负组合。这个 AABB 有个特点它是轴对齐的也就是说它永远平行于世界坐标系的三个轴不会跟着物体的旋转而倾斜。这带来的好处是计算简单坏处是当一个物体斜着摆放时AABB 会比物体的实际轮廓胖一圈标注框会偏大。对于大部分工业设备和立方体状的零件这个误差可以接受一般不超过 10%。但如果目标是细长杆件、斜放的平板AABB 的误差就很大了。这种场景下可以考虑用Mesh.bounds结合localToWorldMatrix手动变换 8 个顶点得到的框会贴合得多代价是每个物体要遍历顶点性能开销大一些。我的做法是默认用Renderer.bounds对误差敏感的目标类型单独用一个配置表切换算法。提示轴上对齐的包围盒在投影到屏幕后仍然是一个轴对齐矩形因为投影是线性的。所以我们只要投影 8 个角点、取 min/max 就够了不用再考虑旋转包围盒。4.2 从世界坐标到像素框八点投影法核心的换算流程是把 8 个角点分别通过Camera.WorldToViewportPoint转到视口坐标x、y 在 0 到 1 之间z 是到相机的距离再乘以图片的宽高得到像素坐标。因为视口坐标的原点在左下角而图片的像素坐标原点在左上角所以 y 需要翻转一下pixelY (1 - viewportY) * imageHeight。代码大概是这样public static bool TryProjectBounds(Camera cam, Bounds b, int imgW, int imgH, out Rect box) { Vector3 c b.center; Vector3 e b.extents; float minX float.MaxValue, minY float.MaxValue; float maxX float.MinValue, maxY float.MinValue; bool anyValid false; for (int i 0; i 8; i) { Vector3 corner c new Vector3( (i 1) 0 ? -e.x : e.x, (i 2) 0 ? -e.y : e.y, (i 4) 0 ? -e.z : e.z); Vector3 vp cam.WorldToViewportPoint(corner); // z 必须大于近裁剪面否则点在相机背后投影结果会被翻转 if (vp.z cam.nearClipPlane) continue; float px vp.x * imgW; float py (1f - vp.y) * imgH; minX Mathf.Min(minX, px); maxX Mathf.Max(maxX, px); minY Mathf.Min(minY, py); maxY Mathf.Max(maxY, py); anyValid true; } if (!anyValid) { box default; return false; } float x0 Mathf.Clamp(minX, 0f, imgW); float y0 Mathf.Clamp(minY, 0f, imgH); float x1 Mathf.Clamp(maxX, 0f, imgW); float y1 Mathf.Clamp(maxY, 0f, imgH); // 过滤掉过小或者完全出屏的框 if (x1 - x0 3f || y1 - y0 3f) { box default; return false; } box new Rect(x0, y0, x1 - x0, y1 - y0); return true; }这段代码里有两个地方特别关键也是新手最容易翻车的。一是vp.z nearClipPlane的判断WorldToViewportPoint对相机背后的点会返回一个镜像的结果x 和 y 会被翻转如果不剔除整个包围盒会算出完全离谱的坐标。二是最后的Clamp和最小尺寸过滤目标只露一条边的时候会产生宽高接近 0 的框这种框对训练是纯噪声必须丢掉。4.3 边界裁剪、可见性判断与遮挡剔除裁剪和过滤是三个不同层次的判断别混在一起。裁剪是把超出画面的框切回画面内上面代码里的Clamp就是干这个。切完之后框的形状和数据都还对只是不完整了。如果你不希望训练数据里有半截目标可以在裁剪前先判断如果原始框有四分之一以上的面积在画面外就整条丢弃。可见性判断是判断目标到底在不在视锥体内。完整的做法是用GeometryUtility.CalculateFrustumPlanes拿到 6 个裁剪面再用GeometryUtility.TestPlanesAABB快速判定比自己写计算省事而且它内部做了优化。这个判断的好处是能把完全在画面外的目标直接跳过省下 8 次投影计算。不过在采集场景里物体数量一般不多直接用投影结果判断也够用。遮挡剔除是最容易被忽略的一环。假设轨道绕到设备背面前方有个柱子把设备挡住了但包围盒投影出来仍然在画面内这个框就成了幽灵框——训练时模型会看到框里根本没有目标精度直接掉。解决办法是从相机位置向目标包围盒中心打一条射线static bool IsOccluded(Camera cam, Vector3 worldCenter, Transform selfRoot) { Vector3 origin cam.transform.position; Vector3 dir worldCenter - origin; float dist dir.magnitude; if (dist 0.001f) return false; if (Physics.Raycast(origin, dir.normalized, out RaycastHit hit, dist, ~0, QueryTriggerInteraction.Ignore)) { // 打到的不是自己含子物体就认为被遮挡 return !hit.transform.IsChildOf(selfRoot) hit.transform ! selfRoot; } return false; }只打一条射线会漏掉中心被挡但边缘可见的情况。更稳的做法是沿包围盒的对角线再补两条射线三条里只要有一条通就认为可见。代价是开销翻三倍但你可以在采集前先按目标数量评估数量不多就全开。注意射线检测要求目标身上有 Collider。如果没有可以给每个采集目标自动挂一个BoxCollider然后根据Renderer.bounds设置它的中心点和尺寸运行时动态生成即可。4.4 输出格式YOLO txt 与 COCO json 的落地写法标注格式主要就是这两种看你的训练框架。YOLO 格式是每个图配一个同名 txt每行一个目标格式是class_id cx cy w h全部归一化到 0 到 1。归一化的时候注意cx 和 cy 是框中心w 和 h 是框的宽高都除以图片尺寸float cx (box.x box.width * 0.5f) / imgW; float cy (box.y box.height * 0.5f) / imgH; float bw box.width / imgW; float bh box.height / imgH; sb.AppendLine(${classId} {cx:F6} {cy:F6} {bw:F6} {bh:F6});保留 6 位小数足够了1280 宽的图一位小数对应 0.1 像素6 位已经远低于像素精度。COCO 格式是一个总的 json 文件bbox用的是像素坐标格式是[x, y, w, h]x 和 y 是左上角。同时还要写image_id、category_id、iscrowd这些字段。我的做法是用一个简单的结构体列表先攒着全部采集完再一次性序列化成 json避免每张图都去读写大文件。类别的映射建议单独放一个classes.txt一行一个类别名行号就是 class_id。这样训练脚本读类别列表和读标注文件用的是同一份配置不会出现训练时类别顺序和标注时不一致这种低级但致命的问题。5. 图片截取的实现细节5.1 RenderTexture 方案的完整代码图片落盘的核心思路是把采集相机的输出渲染到一张RenderTexture然后从这张纹理里读出像素编码成 JPG 或 PNG最后写文件。RenderTexture rt; Texture2D readTex; void InitCaptureRT(int w, int h) { rt new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32); rt.antiAliasing 1; // 关掉 MSAA避免读出边缘异常 rt.Create(); readTex new Texture2D(w, h, TextureFormat.RGB24, false); } void CaptureOne(string path) { captureCam.targetTexture rt; captureCam.Render(); // 手动渲染一帧 RenderTexture prev RenderTexture.active; RenderTexture.active rt; readTex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); readTex.Apply(); RenderTexture.active prev; byte[] jpg readTex.EncodeToJPG(92); File.WriteAllBytes(path, jpg); captureCam.targetTexture null; // 用完记得摘掉否则主屏不显示 }几个参数值得说。antiAliasing 1是因为抗锯齿会在物体边缘混合背景色采集出来的图边缘会有一圈半透明像素对检测模型影响不大但会影响像素级的对比算法。EncodeToJPG(92)的质量参数92 是我试出来的平衡点再往下压电线、螺丝这类细结构会糊再往上文件大小涨幅明显但肉眼看不出差别。JPG 比 PNG 的体积小 3 到 5 倍采集几千张的时候这个差别很关键。captureCam.targetTexture null这行别漏。如果渲染完不摘掉主相机会继续往这张纹理渲Game 视图直接黑屏你会以为程序崩了。5.2 等待渲染完成的正确姿势上面代码用的是同步的Camera.Render()好处是立即生效位置设置完马上就能渲染数据严格对应。但它会阻塞主线程一张图大概几毫秒到几十毫秒采集几千张的时候会明显感觉到卡顿。如果对流畅度有要求可以改用协程 WaitForEndOfFrameIEnumerator CaptureAsync(string path) { yield return new WaitForEndOfFrame(); // 等这一帧渲染彻底结束 RenderTexture prev RenderTexture.active; RenderTexture.active rt; readTex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); readTex.Apply(); RenderTexture.active prev; byte[] jpg readTex.EncodeToJPG(92); File.WriteAllBytes(path, jpg); }WaitForEndOfFrame保证在渲染管线的最后阶段读取这时候纹理内容是完整的。如果你用WaitForSeconds之类的代替很容易读到上一帧甚至空白的内容出一堆纯色图。要特别注意用协程方案时相机位置的设置必须和WaitForEndOfFrame在同一次迭代里且中间不能有其他会改变相机的逻辑。我见过有人把位置设置放在 Update 里、读取放在协程里两者节奏对不上出来的框整体偏移了半个屏幕。5.3 编码与写盘别让磁盘 IO 拖垮帧率EncodeToJPG和File.WriteAllBytes都是同步阻塞操作前者是 CPU 密集后者是 IO 密集。单张图可能感觉不到但累积起来很可观。实测中1280×720 的图编码一次大约 8 到 15 毫秒写到机械硬盘再花 3 到 5 毫秒两张图之间就吃掉 20 毫秒采集速度直接被腰斩。优化手段有这么几个。一是把编码和写盘放到后台线程// 注意Texture2D 的 ReadPixels 必须在主线程只有编码和写盘能挪走 byte[] raw readTex.GetRawTextureData(); int w readTex.width, h readTex.height; Task.Run(() { var t new Texture2D(w, h, TextureFormat.RGB24, false); // 后台线程里创建 Texture2D 做编码是可行的 t.LoadRawTextureData(raw); t.Apply(); File.WriteAllBytes(path, t.EncodeToJPG(92)); Object.Destroy(t); });二是用AsyncGPUReadback从 GPU 直接异步拷回 CPU省掉一次同步等待适合数据量特别大的情况。三是把输出目录放到 SSD 上这一点听起来很土但收益是立竿见影的。我有一次把输出路径写在网络盘上采集速度从每秒 3 张掉到每秒 0.4 张排查了半天才发现是网络盘的延迟问题。四是分批落盘先把几十张图的字节数组攒在内存里满了再一次性批量写。这个做法对机械硬盘特别有效因为减少了寻道次数。6. 批量采集的节奏控制与性能调优6.1 用固定步长 手动更新相机保证一致性批量采集最容易出的问题是相机没更新到位就截图了。Cinemachine 的虚拟相机默认是在LateUpdate里更新的如果你在协程里设置完CameraPosition立刻就截图拿到的可能是上一帧的相机状态。解决办法有两个。一是把CinemachineBrain的UpdateMethod设成ManualUpdate然后自己在设置完位置之后显式调用brain.ManualUpdate()dolly.CameraPosition i * step; brain.ManualUpdate(); // 强制立即刷新虚拟相机 captureCam.transform.SetPositionAndRotation( mainCam.transform.position, mainCam.transform.rotation); captureCam.fieldOfView mainCam.fieldOfView; yield return new WaitForEndOfFrame(); // 这里再截图保证是刚设置的位置二是干脆不用自动更新自己在脚本里算出相机的位置和朝向后直接写transformCinemachine 只做可视化和曲线采样。这种做法最可控代价是放弃了 LookAt 的自动朝向需要自己算旋转。我推荐第一种改动小稳定性也够。实测下来用ManualUpdate之后连续采集 2000 张图标注框和图像的对齐误差稳定在 1 个像素以内。6.2 内存与磁盘的平衡采集过程中内存增长主要来自三个地方每张图创建的Texture2D没销毁、标注数据全攒在内存、以及 RenderTexture 没有释放。前两个是大头。Texture2D一定要显式Destroy或者复用同一个实例反复ReadPixels我上面就是这么做的别每次new一个。标注数据如果用一个大的 List 攒着写 COCO json几千张图可能占几十 MB问题不大但几十万张就得考虑边采边分批写文件了。磁盘空间要提前算1280×720 的 JPG 按质量 92 算平均一张 150KB 左右一万张就是 1.5GB加上标注文件2GB 上下。如果目标是几万张建议直接外挂一块专门的 SSD 来做数据盘。另外采集过程中屏幕会一直刷新GPU 负载不低。如果你不需要实时观察可以把 Game 视图的分辨率调小甚至把 Scene 视图切到隐藏状态能省下一些 GPU 开销给渲染管线。6.3 采集结果的快速校验采完之后别急着拿去训练先用几个自动化手段快速过一遍能省下大量返工时间。第一个校验是尺寸校验遍历目录下所有图片读一下宽高看是不是全部等于设定值。只要有一张不对说明中间某次渲染的参数被改了。第二个是空标注率统计有多少张图的标注文件是空的。如果空标注率超过 20%说明轨道设计有问题很多位置根本拍不到目标需要调整轨道半径或者目标布局。如果空标注率是 0反而要警惕——可能遮挡剔除没生效幽灵框全被算进去了。第三个是框的面积分布把所有框的面积归一化后画个直方图。健康的分布应该集中在中间区域如果出现大量面积极小小于画面 0.5%或者极大大于 80%的框说明要么有噪声要么相机贴得太近。我一般写一个编辑器脚本OnGUI里直接把上述统计打出来采集完点一下就出结果几秒钟的事。7. 常见问题与排查技巧实录7.1 问题速查表现象最可能的原因排查方向输出图片全黑或全白RenderTexture 未激活 / 相机未渲染检查RenderTexture.active是否设置、是否调用了Render()标注框整体偏移半个屏幕相机位置更新时机与截图不一致改用ManualUpdate并在截图前同步相机 transform框的位置上下颠倒视口坐标 y 未翻转检查(1 - vp.y)是否漏乘框尺寸明显偏大用了 AABB 包围盒目标斜放改用 Mesh 顶点变换法出现明显的幽灵框遮挡剔除未开启或 Collider 缺失检查目标是否挂 Collider相机背后也出框未过滤vp.z nearClipPlane补上 z 值判断相机朝向在拐弯处剧烈抖动LookAt 无软区或阻尼为零加 Composer 软区或适度加阻尼采集到一半卡死内存泄漏Texture2D 未销毁复用纹理实例及时 Destroy输出图片分辨率不对采集相机走了自动 aspect手动锁定 width/height/aspect采集速度极慢磁盘 IO 或编码阻塞换 SSD、异步编码、分批落盘7.2 坐标换算踩坑合集坐标换算是这套方案里最容易出错的地方我单独抽出来讲三个坑。第一个是WorldToScreenPoint和WorldToViewportPoint的区别。前者直接返回像素坐标后者返回 0 到 1 的归一化坐标。用前者的话你就不需要手动乘宽高但要注意它返回的 y 也是从左下角算起的一样要翻转。而且WorldToScreenPoint返回的像素坐标依赖当前相机的pixelWidth如果你渲染到 RenderTexture相机实际像素尺寸是 RT 的尺寸容易混淆。所以我更推荐用WorldToViewportPoint自己乘图片尺寸逻辑清晰。第二个是相机背后点的镜像问题前面提过这里再强调一次WorldToViewportPoint对 z 为负的点返回的 x 会变成1 - xy 会变成1 - y。一个在相机左后方的点可能被算成画面右侧直接毁掉整个框。判断条件用vp.z 0或者vp.z nearClipPlane都行我习惯用后者更保险。第三个是aspect 不匹配。如果采集相机的aspect是自动计算的而它渲染到 RenderTexture 时没能正确获取 RT 的宽高比画面就会被拉伸投影结果也跟着错。解决办法是给采集相机设置camera.aspect (float)rt.width / rt.height;手动锁死。7.3 渲染与平台相关的坑阴影问题。采集场景里如果有实时阴影相机的移动会导致阴影贴图重新计算不同帧之间阴影的偏移可能影响一致性。但做目标检测的话阴影是很好的数据增强能提升模型在真实场景下的鲁棒性所以我的建议是保留阴影但要保证阴影参数在整个采集过程中不变别中途调光照角度。抗锯齿与边缘。前面提过关掉 MSAA。另外如果用了后处理Post Processing要注意后处理可能改变画面的色彩和对比度标注框的位置不会受影响但颜色变化会影响模型泛化。如果出于真实感考虑要用后处理那就固定一套参数全程不改。WebGL 平台的限制。如果项目要发布到 WebGL 上跑ReadPixels需要渲染完成后立即调用否则缓冲区可能已被清空。这时候用WaitForEndOfFrame是必须的。另外 WebGL 下写文件只能通过 IndexedDB 做虚拟文件系统写入失败是常见问题采集场景建议还是在桌面端跑WebGL 平台只用来做展示。分辨率设置。如果需要在运行时动态改分辨率Screen.SetResolution会影响主相机但采集相机用的是 RenderTexture 的固定尺寸不受影响。两边的分辨率不一致的时候Game 视图里看到的框和输出图里的框位置比例是对的但像素尺寸不同调试时别被误导。串口与外部信号触发采集。有些产线场景需要用外部信号触发采集比如工件到位后拍一张。这时候可以通过串口接收信号在回调里执行单次采集逻辑。要注意串口回调是在独立线程里的不能直接操作 Unity 的相机和 Texture必须通过一个线程安全的队列把请求丢回主线程处理。最后分享一个我在实际项目里用了很久的小技巧采集脚本里加一个断点续采机制。每采集完一张把当前索引写到一个progress.txt里脚本启动时先读这个文件从上次中断的位置继续。这样即使跑到一半因为磁盘满或者误操作中断也不用从头再来尤其是那种要跑几个小时的环绕轨道这个机制能救命。采集完之后我会用一个小脚本抽样 20 张图把标注框画上去看一眼确认对齐没问题再打包送训练。