上周又被问了那个老问题:在 HDevelop 里明明用鼠标在图像上圈了个区域、旁边还写了"缺陷"两个字,write_image存出来一看,干干净净,框没了,字也没了。这不是算子写错了,而是把窗口叠加层和图像像素矩阵当成了一回事。HALCON 的图像涂写(paint)之所以容易踩坑,根子就在这条分界线上:dev_display、dev_set_color、disp_text玩的是窗口那层"贴纸",write_image只搬运底下那张"照片"。
这篇东西我就把 HALCON 里跟"图像、区域涂写"相关的算子摊开讲一遍:哪些算子是真改像素、paint_region和overpaint_region该怎么选、Grayval里藏着什么类型陷阱、鼠标交互圈的 ROI 怎么回填、以及"往图上写文字"这件 HALCON 一直没给直通车道的事到底怎么绕。内容偏实战,代码以 HDevelop 语法为主,C#/C++ 调 HALCON 的写法逻辑一样,只是对象句柄的传递方式不同。适合已经能跑通threshold+connection,但一到"标个红框输出给客户看"就卡住的同学。
1. 图像涂写的三条技术路线,选错一条就白干
1.1 窗口图层与像素数据是两套独立系统
先把这张表记住,后面所有坑几乎都是它派生出来的。
| 你做的动作 | 典型算子 | 作用对象 | 能否被write_image保存 |
|---|---|---|---|
| 改显示颜色 | dev_set_color/set_color | 窗口叠加层 | 否 |
| 显示文字 | disp_text/write_string | 窗口叠加层 | 否 |
| 显示区域轮廓 | dev_display/set_draw | 窗口叠加层 | 否 |
| 把区域填成灰度 | paint_region | 图像像素矩阵 | 是 |
| 原地改写像素 | overpaint_region | 图像像素矩阵 | 是 |
| 生成二值掩膜图 | region_to_bin | 新图像 | 是 |
| 缩小定义域 | reduce_domain | 图像的 domain 标记 | 部分格式会存成裁剪图 |
这张表的关键信息是第三列。HDevelop 的图形窗口本质上是一块画布,你dev_display(Image)是把像素画上去,然后dev_set_color('red')+dev_display(Region)是在像素上面又刷了一层半透明的红。dev_clear_window一按,这层红就没了,底下的像素一个都没变。很多新手做完演示,按了dump_window_image才发现能存下来,是因为那个算子截的恰恰是"画布",也就是叠加层和底图合成后的结果——这一点在第 4 节会细讲。
所以在动手涂写之前,先问自己一个问题:我要的是"看得见"还是"存得下"?如果只是调试时看得见,dev_set_color一行就够,不要动像素,动了反而污染后续的min_max_gray、intensity这类统计算子。如果这张图要落地成文件、要传给下游、要参与后续算法计算,那必须走paint_region这条真改像素的路。
1.2 reduce_domain 不是涂写,它只是贴了一张"允许通行"的标签
reduce_domain(Image, Region, ImageReduced)这个名字太有迷惑性,很多人以为它把区域外的部分"裁掉"了。它没有。它只是在图像对象上挂了一个 domain,告诉后续算子"只处理这块范围内的像素"。
区别在哪?reduce_domain之后,区域外的像素值原封不动躺在内存里,get_image_size返回的宽高也不变。而paint_region是把区域内的像素值真的改成你指定的灰度。一个改变的是"计算范围",一个改变的是"数据本身"。
我见过一个很典型的事故:有人想给模板匹配做带遮盖的模板,用reduce_domain把干扰区域排除了,然后create_shape_model拿ImageReduced去训练。结果模板里干扰区域还是被算了进去——因为create_shape_model内部对 domain 的处理方式和你想象的不一样,它不是简单地"忽略 domain 外像素"。这种场景正确的做法是先用paint_region把那块干扰涂成一个和背景接近的灰度,抹掉它的梯度,再建模板。
1.3 真正需要动像素的四个高频场景
我梳理了一下自己项目里用到涂写的场合,基本跑不出这四类:
第一类是打靶标记。检测出缺陷后要把缺陷区域标成红色导出,给产线人员或者客户看,这是最刚需的场景,也是paint_region的主场。
第二类是生成掩膜。把 ROI 以外的区域涂成 0,这样后面做min_max_gray、自适应阈值、均值滤波时,边缘不会因为 ROI 外的亮斑产生拖尾。这一招在检测圆形工件边缘时特别管用。
第三类是给算法喂干净的输入。比如一张图上有个固定的反光点,每次都误报,与其在算法里加一堆排除逻辑,不如在预处理阶段用paint_region把它涂成周围背景的中值灰度,后面整条链路的复杂度都能降一档。
第四类是可视化叠加。把深度图或者热力图伪彩之后,再把缺陷轮廓叠上去,生成一张能直接进报告的综合图。
2. paint_region 与 overpaint_region:参数里藏着的三个决定
2.1 Grayval 是元组,长度必须等于通道数
这是最容易被忽略的一条。paint_region(Region, Image, ImageResult, Grayval, Type)里的Grayval不是单值,而是一个元组,长度必须等于Image的通道数。
单通道灰度图,写一个数:paint_region(Defect, Image, ImageMarked, 255, 'fill')。
三通道彩色图,写三个数:paint_region(Defect, Image, ImageMarked, [255, 0, 0], 'fill'),得到的是纯红。这里顺序是 R、G、B,不是 B、G、R,写反了会得到蓝色,别问我怎么知道的。
如果你只给了[255]却喂了一张三通道图,HALCON 不会自动帮你补齐,行为是不可预期的。稳妥的做法是先count_channels(Image, Channels)判断一下,再决定 Grayval 的写法。
还有一个隐藏维度:Grayval的长度是通道数,不是你想要的"颜色数"。有些朋友想涂一个渐变,那得自己算好每个像素值再用gen_image_proto之类的构造,paint_region只支持常量填充。
2.2 Type 的 'fill' 与 'margin' 决定是涂面还是描边
Type只有两个合法取值:
'fill':把区域内部所有像素全部改成Grayval,就是实心涂。'margin':只把区域的边界像素改成Grayval,内部不动。
想给缺陷画个红框,最直觉的做法是gen_rectangle1生成矩形区域,然后paint_region(..., 'margin')。但实测下来'margin'的线条只有 1 像素宽,在 500 万像素的图上导出成 PNG 再缩放到 PPT 里,基本看不见。
我自己的惯用做法是用两个矩形做差,得到指定粗细的边框环:
gen_rectangle1 (BoxOuter, Row1, Column1, Row2, Column2) gen_rectangle1 (BoxInner, Row1 + 3, Column1 + 3, Row2 - 3, Column2 - 3) difference (BoxOuter, BoxInner, BoxRing) union2 (BoxRing, DefectRegion, ToPaint) paint_region (ToPaint, ImageCopy, ImageMarked, [255, 0, 0], 'fill')这样边框宽度由3这个偏移量精确控制,想要多粗就调多大,比跟set_line_width较劲可靠得多。而且因为最后统一用'fill'涂,一次调用搞定,比先涂面再描边省一半时间。
2.3 overpaint_region 原地改写带来的连锁反应
overpaint_region(Image, Region, Grayval, Type)和paint_region的关系,类似"就地修改"和"返回新对象"。前者直接把传进来的Image改掉,后者生成一张ImageResult。
那要不要为了省内存一律用overpaint_region?我的经验是分情况:
- 如果这张图后面还要跟原图比对(比如算差异图),必须用
paint_region,先把原图copy_image一份留着。 - 如果从头到尾就是"预处理一下然后扔掉",
overpaint_region更省事,也少一次大图的内存分配。
但overpaint_region有个非常隐蔽的副作用:HALCON 的图像对象是写时复制(copy-on-write)的,如果你有另一个变量还指向同一张底层数据,改写之后那个变量看到的内容也会变。曾经有个项目里我把图存进一个数组做批处理,中间用overpaint_region做了预处理,结果最后汇总输出时发现前面几帧的原始数据全被改花了。排查了两个小时才定位到这一行。所以但凡图像对象存在多个引用,老老实实用paint_region生成新对象。
还有一个坑是overpaint_region对 domain 的处理。它只改 domain 内的像素,region 里超出 domain 的部分会被静默忽略,不报错、不警告。等你发现"涂了半天有一块没涂上",回头查才发现是前面某一步reduce_domain把 domain 缩了。
2.4 灰度值必须匹配图像类型,否则涂了等于没涂
这张表建议贴在显示器边上。
| 图像类型 | 合法灰度范围 | 涂 255 会怎样 |
|---|---|---|
byte | 0 ~ 255 | 正常,最亮白 |
uint2 | 0 ~ 65535 | 只是偏暗的灰,远不到白 |
int2 | -32768 ~ 32767 | 正常但容易溢出误解 |
real | 一般 0.0 ~ 1.0 | 严重超范围,显示被截断成纯白 |
direction | 角度值 | 语义被破坏,后续方向类算子全乱 |
最常见的翻车是real图像。你从某个流程拿到一张real类型的深度图或归一化图,顺手paint_region涂了个255,结果那一块全白,和周围完全脱节,而且这个 255 传下去做scale_image时会把整张图的动态范围拉坏。
正确姿势是先确认类型:
get_image_type (Image, Type) if (Type != 'byte') convert_image_type (Image, ImageByte, 'byte') endif或者按类型给对应的值:real图涂1.0,uint2图涂65535。我一般会在工程里写一个小工具函数,输入图像和区间,自动算出一个"看起来是红色"的灰度值,避免每个项目重新试。
3. 区域侧的准备:从鼠标圈选到批量合并
3.1 交互绘制:set_draw 是窗口级的,别和 dev_set_draw 搞混
做半自动标注工具时,draw_region系列是主力。但很多人第一次用会发现"鼠标画出来的是一片黑,我想要的是轮廓"。原因在set_draw。
set_draw(WindowHandle, Mode)的Mode取'fill'还是'margin',决定的是鼠标拖动过程中视觉反馈的形状,以及draw_region返回的区域是填充的还是描边的。注意,这个状态是挂在WindowHandle上的,每个窗口各管各的。而 HDevelop 里还有一个全局的dev_set_draw,它作用于当前活动窗口,是给dev_display用的。两套东西名字像,作用域不同,混用会出现"我明明设成 margin 了,画出来还是实心"的情况。
常用的一族交互算子,我列个清单方便对照:
| 算子 | 返回值 | 适合场景 |
|---|---|---|
draw_region | 任意多边形区域 | 不规则缺陷标注 |
draw_rectangle1 | 两个角点行列坐标 | 正矩形 ROI,最常用 |
draw_rectangle2 | 中心、角度、半长半宽 | 有旋转的矩形 |
draw_circle | 圆心 + 半径 | 圆形工件 |
draw_ellipse | 椭圆参数 | 变形目标 |
draw_polygon | 多边形区域 | 复杂边界 |
其中draw_rectangle1我最推荐,因为它返回的是坐标而不是区域,你可以在拿到坐标后做加工——比如向外扩 5 像素留边距,或者坐标对齐到偶数方便后续处理。而draw_region返回的是已经光栅化的区域,想再调整就得走shape_trans或者形态学,灵活性差一些。
3.2 行列顺序:Row 在前,Column 在后
这个坑简单到不好意思写,但每个月都能见到有人在群里问"为什么我的框画在了左上角"。
HALCON 所有跟坐标有关的算子,参数顺序都是Row, Column,也就是先行后列。gen_rectangle1(Region, Row1, Column1, Row2, Column2),其中 Row 是纵坐标(从上往下数),Column 是横坐标(从左往右数)。而get_image_size(Image, Width, Height)返回的却是Width, Height,也就是先列后行。
于是就有了这个经典组合错误:
get_image_size (Image, Width, Height) gen_rectangle1 (BigBox, 0, 0, Width, Height) // 错了,把宽当成了行如果图是 1920×1080,这一行生成的是一个从 (0,0) 到 (1920,1080) 的矩形,纵向超出了图像 840 像素。在没有 domain 限制的情况下它不报错,只是超出部分被忽略,看起来"框的下半部分没了"。
正确写法是gen_rectangle1(BigBox, 0, 0, Height - 1, Width - 1),注意还要减 1,因为坐标是 0 基的。这个减 1 也是高频疏漏,边界那一行/列经常就这么丢了。
3.3 多个区域先 union 再涂,省时又省心
假设一张图里有 40 个缺陷,逐个paint_region就是 40 次调用,每次都要分配一张新图,内存和耗时都是线性叠加。实测在一张 4096×3000 的图上,40 次单区域paint_region比一次合并区域后涂写慢 6 到 8 倍。
优化思路很朴素:
connection (DefectAll, DefectList) select_shape (DefectList, Selected, 'area', 'and', 50, 99999) union1 (Selected, DefectUnion) copy_image (Image, ImageCopy) paint_region (DefectUnion, ImageCopy, ImageMarked, [255, 0, 0], 'fill')union1把区域数组压成一个区域,paint_region一次搞定。唯一需要注意的是,如果这些区域颜色各不相同(比如按缺陷等级用红黄绿三色标注),那必须分组涂:按等级select_shape分成三组,每组union1后各涂一次,总共三次调用,依然比 40 次快得多。
这里顺带说一句paint_region内部对区域的扫描方式。它对每个区域做边界框定位,然后逐行扫描填充。所以如果你的区域是那种又大又分散的(比如图像左右两端各一个大块),合并后边界框覆盖了整幅图,扫描效率反而不如分开涂。这种情况建议按位置分簇,别一股脑union1。
4. 往图像里烧文字:没有直通车道,但有两条绕行路
4.1 dump_window_image:把窗口当画布截下来
"HALCON 能在原图上写文字吗"这个问题,热词里也出现了。直接回答:截至目前的算子集里,没有一个write_string_to_image式的直接接口。write_string(WindowHandle, String)写的是窗口,不是图像。
但这不代表做不到。业界最通用的路子是"先在窗口里画好,再把窗口整个截成图像":
* 1. 开一个和图像等大的窗口,保证 1:1 映射 get_image_size (ImageMarked, Width, Height) dev_open_window (0, 0, Width, Height, 'black', WindowHandle) dev_set_part (WindowHandle, 0, 0, Height - 1, Width - 1) * 2. 显示底图 dev_display (ImageMarked) * 3. 设置文字属性 set_font (WindowHandle, 'mono') set_color (WindowHandle, 'white') set_tposition (WindowHandle, 20, 20) write_string (WindowHandle, 'DEFECT 3 PCS') * 4. 截图 dump_window_image (ImageWithText, WindowHandle)几个关键点必须说清楚:
窗口尺寸必须等于图像尺寸。dev_open_window的宽高直接影响dump_window_image的输出分辨率。窗口开小了,截出来的图就被压缩了,再叠回原图会糊。窗口开大了,输出比原图还大,坐标全对不上。
dev_set_part保证 1:1。如果窗口大小和图像大小一致,但没用dev_set_part把显示范围设成完整图像,HALCON 默认会自适应缩放。一旦缩放,你在窗口里的 (20,20) 映射到图像里就不是 (20,20) 了,位置会飘。
set_tposition的坐标是窗口坐标,不是图像坐标。在 1:1 且无缩放的前提下这两者才等价。这个前提一旦被破坏,文字位置就不可控。
换行用\n。这对应热词里"halcon 换行符号怎么设置"。HDevelop 的字符串字面量支持转义序列,write_string(WindowHandle, 'LINE 1\nLINE 2')就能出两行。要注意的是字号和行距是绑定的,想让行距大一点,得手动分两次set_tposition+write_string,HALCON 没有独立行距参数。
字体。set_font可以传'mono'、'sans'、'serif'这类通用名,也可以传字体文件的完整路径。项目部署时我强烈建议用文件路径固定字体,因为不同机器上字体渲染结果不一样,同一份代码在两台机器上跑的标注图字号会差一截,对不齐会很难看。生产环境我用.ttf路径,随工程一起打包。
如果你只是想量一下文字占多大地方好排版,可以用get_string_extents(WindowHandle, String, Ascent, Descent, TextWidth, TextHeight),拿到宽高之后再决定set_tposition往哪儿放。
4.2 通道对齐:彩色文字到底往哪几个通道写
dump_window_image出来的是三通道 byte 图像,也就是窗口渲染的 RGB 结果。你要把它叠回原来的彩色图上,就得做通道级的合并。
假设底图是ImageMarked(三通道),文字图是ImageWithText(三通道),希望文字盖在底图上:
* 用文字图做前景,按灰度做掩膜 rgb1_to_gray (ImageWithText, TextGray) threshold (TextGray, TextMask, 1, 255) * 逐通道合并 access_channel (ImageMarked, ChR, 1) access_channel (ImageMarked, ChG, 2) access_channel (ImageMarked, ChB, 3) access_channel (ImageWithText, TR, 1) access_channel (ImageWithText, TG, 2) access_channel (ImageWithText, TB, 3) paint_region (TextMask, ChR, ChR2, 255, 'fill') paint_region (TextMask, ChG, ChG2, 255, 'fill') paint_region (TextMask, ChB, ChB2, 255, 'fill') compose3 (ChR2, ChG2, ChB2, ImageFinal)上面这段为了演示逻辑写得很啰嗦,实际项目里更简洁的方式是直接用dump_window_image的结果作为整图的一部分——如果文字所在区域不重叠重要内容,直接compose3之后按掩膜替换就行。
这里有个细节要留神:dump_window_image的背景色和底图的差异。如果窗口背景是黑的,而底图是白底,那你要么把窗口背景设成和底图一致,要么严格用掩膜只取文字笔画,别把整块背景一起盖上去。我吃过这个亏,图上多了一个大黑方块,排查半天才发现是背景色。
4.3 另一条路:把字形拆成区域,可控但费工
如果项目要求文字必须是纯二值、无抗锯齿、可精确定位到像素(比如做样本生成、做二值化对比测试),上面那条窗口截图的路子就不合适了,因为抗锯齿会引入中间灰度。
这时候只能自己造字库区域。思路是把每个字符预先生成一张小图,threshold出区域后存成region文件,用的时候按字符查表,gen_region出来平移到目标位置,最后union1+paint_region。
这条路我做过一次,只覆盖了 0-9 和 A-Z 加几个符号,工作量不小。它的优势是完全确定性的:给定输入,输出像素完全一致,跨机器、跨版本都稳。如果你做的是自动化测试或者需要严格复现的样本生成,这条路的稳定性值得那点工作量。
5. 几个真实踩过的坑与排查链路
5.1 涂了却看不出来:从类型查到 domain
这个现象我归纳成一套固定排查顺序,基本三步能定位:
第一步,查类型。get_image_type(Image, Type)看是不是real。是real而你又涂了255,那就是范围问题,改用1.0或者先转byte。
第二步,查 domain。get_domain(Image, Domain),然后area_center(Domain, DomainArea, _, _),和get_image_size算出来的总像素数比一下。如果 domain 面积远小于总像素,说明前面有reduce_domain缩过范围,你涂的区域如果落在 domain 外面,就是白涂。
第三步,查通道。count_channels(Image, Channels),确认Grayval的长度对得上。三通道图给单值,有时候不报错但效果诡异。
还有个小概率情况:涂的颜色和背景太接近。用min_max_gray量一下涂写前后的局部灰度,确认差值够大。做可视化输出时,涂写颜色的选择和底图的对比度要专门调一次,别用默认的红——如果底图本身就是暖色调,红框根本跳不出来。
5.2 循环里涂写导致耗时暴涨的定位过程
遇到过一个批处理任务,单张图处理 200ms,批量跑 500 张用了 8 分钟,算下来单张接近 1 秒。用count_seconds在关键节点打点,发现时间全耗在一个循环里的paint_region上。
那段代码大概是这样:
for Index := 0 to |Defects| - 1 by 1 select_obj (Defects, OneDefect, Index + 1) paint_region (OneDefect, ImageWork, ImageWork, [255, 0, 0], 'fill') endfor问题有两点。一是每次paint_region都生成一张新的三通道大图,500 个缺陷就是 500 次全图内存分配和拷贝;二是输入输出用了同一个变量名,HALCON 内部要做引用检查,额外开销。
改成先合并再涂之后:
union1 (Defects, AllDefects) paint_region (AllDefects, ImageCopy, ImageMarked, [255, 0, 0], 'fill')单张图的这部分耗时从 700ms 降到 90ms 左右。这个案例我后来写进了组内的规范:任何在循环体内对大图做paint_region的写法,先想想能不能搬到循环外。
还有一种情况不能合并:需要按缺陷等级涂不同颜色。那就按等级分组,组数远小于缺陷数,收益依然很大。
5.3 涂完再 reduce_domain,输出图被裁了
这个坑发生在导出环节。流程是:reduce_domain限定检测区域 → 检测 →paint_region标注 →write_image导出。导出的 PNG 只有检测区域那一块,周围全没了。
原因是write_image会尊重图像的 domain。你的图像经过reduce_domain之后 domain 已经不是全图了,write_image就按 domain 的边界框输出。
解决办法是导出前调full_domain:
full_domain (ImageMarked, ImageFull) write_image (ImageFull, 'png', 0, 'result.png')full_domain把 domain 恢复成整幅图范围。注意它不改像素值,只是把 domain 的标记恢复,所以原来 domain 外那些"从未被处理过"的像素会原样出现在输出里——如果那些区域的像素值本来就是原图数据,那没问题;如果图像是gen_image_const造出来的空图,那块就是黑的。
如果不想让 domain 外的内容露出来,可以先paint_region把背景统一涂成白或黑,再full_domain,这样导出的是干净的白底标注图。
另外提一个容易混的算子:crop_domain(Image, ImageCropped),它是真的把图像裁成 domain 的边界框,宽高都变了。什么时候用crop_domain?当你的下一步只需要那块 ROI 的像素、不需要保留原图尺寸时用它,能省内存也能提速。什么时候用full_domain?要保留原图坐标系时用它,因为crop_domain之后所有坐标都变了,你之前记下的缺陷坐标全部失效。
6. 一个完整小案例:缺陷区域标红并输出可交付图片
6.1 流程骨架与逐段代码
把前面所有内容串起来,做一个"检测 + 标注 + 导出"的最小可用流程。假设输入是一张有反光干扰的工件图。
* ---------- 1. 读图与信息采集 ---------- read_image (Image, 'workpiece_01.png') get_image_size (Image, Width, Height) count_channels (Image, Channels) get_image_type (Image, ImageType) * ---------- 2. 预处理:抹掉固定反光区 ---------- * 反光区坐标是工艺固定的,直接涂成背景灰度 gen_rectangle1 (GlareArea, 120, 1680, 260, 1880) if (ImageType == 'byte') BackGray := 128 else BackGray := 0.5 endif copy_image (Image, ImageClean) paint_region (GlareArea, ImageClean, ImageClean, BackGray, 'fill') * ---------- 3. 缺陷检测 ---------- rgb1_to_gray (ImageClean, GrayImage) dyn_threshold (GrayImage, mean_image(GrayImage, GrayMean, 21, 21), \ DefectRaw, 15, 'dark') connection (DefectRaw, DefectList) select_shape (DefectList, DefectSelected, 'area', 'and', 80, 999999) * ---------- 4. 生成标注区域:缺陷面 + 外扩边框 ---------- union1 (DefectSelected, DefectUnion) smallest_rectangle1 (DefectSelected, Row1, Col1, Row2, Col2) gen_rectangle1 (BoxOuter, Row1, Col1, Row2, Col2) gen_rectangle1 (BoxInner, Row1 + 3, Col1 + 3, Row2 - 3, Col2 - 3) difference (BoxOuter, BoxInner, BoxRing) union2 (BoxRing, DefectUnion, ToMark) * ---------- 5. 涂写到彩色副本上 ---------- copy_image (Image, ImageMarked) paint_region (ToMark, ImageMarked, ImageMarked, [255, 0, 0], 'fill') * ---------- 6. 恢复全图 domain 并导出 ---------- full_domain (ImageMarked, ImageOut) write_image (ImageOut, 'png', 0, 'result_marked.png')这段代码有几个地方值得展开说。
第 2 步的固定反光区处理,坐标是硬编码的。这在单工位设备上完全可接受,因为相机和工件的相对位置是锁死的。判断图像类型来选灰度值这一步不能省,real图涂 128 会直接废掉。
第 4 步的smallest_rectangle1我特意用了它而不是smallest_rectangle2。区别在于smallest_rectangle1返回的是正矩形的四个极值坐标,不管你前面有多少个缺陷,它只输出一组坐标——也就是把所有缺陷的并集框住。如果你要每个缺陷单独一个框,得先select_obj逐个算,再union1合并所有框。这里为了代码简洁用了整体框,实际项目里按需选。
第 5 步的copy_image是关键。因为前面第 2 步已经用paint_region改过ImageClean了,如果标注也涂在ImageClean上,导出的图里反光区会带着灰块,客户看了会问"这块是什么"。保留原图Image作为标注底图,视觉上更干净。
6.2 输出前的三点自检
导出之前,我固定会做三件事,做完基本不会返工。
自检一:确认涂写区域面积占比合理。area_center(ToMark, MarkArea, _, _)拿到面积,除以Width * Height。如果占比超过 30%,要么是检测参数太松把整张图都框进去了,要么是背景被误检,先回头查检测,别急着出图。
自检二:确认导出图的宽高和原图一致。get_image_size(ImageOut, W2, H2)然后和Width, Height比。不一致基本就是 domain 的问题,回第 5.3 节看。
自检三:确认颜色对比。如果底图是暖色调,纯红框会糊在一起,改成纯青或者加白边。这个没有通用公式,我就是导出后打开看一眼,觉得不跳就换色。做可视化这件事,肉眼确认比任何参数都可靠。
顺带说一个我常用的小技巧:如果一张图上要标多个缺陷并且要编号,编号文字用第 4 节的dump_window_image路子加上去,最后整体导出。整个链路的顺序是——先涂写所有区域,再在窗口里显示涂好的图,再叠文字,再dump_window_image,再一次write_image。不要在没涂完的时候就截图,那样文字会被后面的涂写覆盖掉。
实际操作下来,这套"先像素后窗口最后截图"的顺序能省掉大量来回调试的时间。我早年习惯先显示后涂写,结果经常出现文字被红块盖住、编号位置对不上涂写结果的情况,改顺序之后这类问题基本绝迹了。