OpenCV虾苗计数实战:从背光图像到分水岭分割 干水产育苗的朋友大概都有一双数虾苗数到发花的眼睛。我在某水产育苗场做设备调试时被拉去解决“OpenCV虾苗计数”这个需求用电脑摄像头对准浅盘自动报出盘里有多少尾虾苗。听起来是不是觉得很简单真上手才发现难点全在“怎么让虾苗一颗颗分开”和“怎么让程序认得出那是虾苗而不是气泡或脏点”。这篇文章不堆理论就讲我从图像采集到算法调通的全过程以及那些代码和文档里不会写的坑。适合刚接触OpenCV、又正好想解决水产计数问题的朋友照着做。1. 项目思路从哪来虾苗计数的痛点与方案选型1.1 为什么人工计数不靠谱虾苗计数是育苗场里绕不开的活。卖苗要数数算存活率要数数定投喂量也要数数。但凡是亲手数过虾苗的人都清楚这活有多折磨人苗体通常只有一厘米左右半透明喜欢扎堆你刚数完左边右边又游过来几条数着数着就乱了。更现实的问题是一批虾苗数量动辄几万尾用“称重法”抽几袋估算还能凑合但遇到需要精确控制数量的场景比如实验分组、打包出售误差一大很容易引起纠纷。还有一个养殖端容易忽略的点虾苗离水时间越长应激越强。人工拿小网捞出来放进培养皿里一只一只点数体力消耗大不说还会造成机械损伤甚至影响后续成活率。我在现场看到技术员数完一托盘虾苗后手指都在抖。所以养殖场提需求时说得直白能不能把摄像头架在盘子上方拍一张照片几秒钟给我一个数量这就是“OpenCV虾苗计数”的原始场景。它解决的并不是一个高大上的算法难题而是把重复性、低效率的人工点数工作自动化。对于小规模育苗场来说一套普通摄像头加一台电脑的成本远比买专业计数设备低而且后续调整和复现都容易。1.2 为什么先考虑OpenCV而不是深度学习做图像识别的人听到“计数”两个字第一反应可能是训练一个YOLO目标检测模型。确实深度学习在复杂背景下更鲁棒但它需要解决几个前置问题标注几百上千张虾苗图准备GPU训练环境迭代模型结构还要解决小目标密集场景下的漏检。对一个没有算法团队的育苗场来说这个门槛太高了。更何况虾苗图像有一类特性只要把拍摄环境控制好背景和目标的对比度就很稳定传统图像处理完全够用。OpenCV路线的好处非常明显。不需要训练数据和GPU普通电脑装上Python和OpenCV就能跑。算法透明哪里出了问题可以直接看中间结果方便现场调参。单张图的处理速度在毫秒级实时性完全没问题。逻辑可解释出了问题能定位是光照、粘连还是参数设置而不是拿着黑盒模型无从下手。当然如果虾苗密度极大、重叠严重或者采集环境不可控比如直接在水塘里拍传统方法的准确率会崩这个时候确实应该转向深度学习或两者结合。但我这次做的是“浅盘背光固定相机”的可控场景用OpenCV做分割计数是最划算的方案。2. 硬件与采集环境计数准确率的第一道坎2.1 硬件清单与选型逻辑很多初学者把注意力全放在算法上结果到现场发现图像质量不行后面再怎么调参都白搭。虾苗计数这件事图像采集占了至少一半的成功率。我最后定下来的硬件组合是这样的设备推荐选型理由相机普通USB摄像头或500万像素工业相机分辨率不低于1920x1080工业相机更稳定普通摄像头也能跑镜头定焦镜头光圈F4-F8光圈太小进光不足太大景深太浅盘子边缘容易糊支架相机垂直向下固定高度40-60cm俯拍避免透视畸变高度固定保证参数稳定光源LED背光板 白色亚克力扩散板让虾苗呈深色剪影目标轮廓清晰不受环境光影响容器白色搪瓷盘或白色亚克力浅盘白色背景可以增强对比度玻璃和透明亚克力容易反光这里最关键的思路是“背光”。虾苗身体半透明如果顶光从上往下打虾苗和背景的灰度差异很小。改成底部背光后虾苗会挡住背后的光在白色背景上形成明显的暗色剪影二值化的时候特别好切。这个操作比任何图像增强算法都管用。2.2 采样与布景操作规范硬件装好之后采样步骤要固定不然拍出来的图五花八门算法参数很快就会失效。我整理的一套操作流程是这样的用定量取样网捞取虾苗放入装有清水的小桶让虾苗自然分散。将虾苗倒入白色浅盘水深控制在1至2厘米刚好没过虾苗即可。水太浅虾苗贴底成一团水太深虾苗游动会拖出残影。轻轻晃动盘子让虾苗均匀散开然后静置20到30秒等水面完全平静。打开背光板盖好遮光罩避免室外光线干扰。相机自动或手动触发拍照保存原图。拍摄参数我建议设置成快门不低于1/100秒ISO尽量低白平衡固定。如果虾苗还在轻微游动快门太慢会导致边缘模糊后面距离变换和轮廓提取都会出问题。另外绝对不要开闪光灯反光亮点会直接让二值化图变成“满天星”。3. 图像预处理把“看得清”变成“分得开”3.1 灰度化与去噪为什么要走这条链路图像预处理的目标很明确把一张彩色照片变成干净的黑白掩膜让虾苗区域是白色背景是黑色。彩色图信息量大但在这种背光场景下颜色对区分虾苗和背景几乎没有贡献反而会引入反光和边缘色差。所以第一步先转灰度图。原始灰度图通常有传感器噪声尤其是USB摄像头在弱光环境下噪点明显。我习惯先做一次高斯滤波核大小用5x5。高斯滤波的作用是平滑噪声但它也会让边缘变模糊所以核不能太大。3x3在很多情况下不够干净7x7又可能把虾须这种细结构抹掉5x5是折中值。3.2 二值化与形态学清理预处理的核心代码不长但每一步都很关键。import cv2 import numpy as np img cv2.imread(shrimp_sample.jpg) img cv2.resize(img, (1280, 960)) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) # 背光下虾苗比背景暗所以用 THRESH_BINARY_INV 把目标变成白色 _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations2) closed cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations3)二值化用了Otsu自适应阈值。THRESH_BINARY_INV是因为背光图像里虾苗灰度值比白色背景低反相之后目标变成白色前景。Otsu方法的原理是自动找一把灰度阈值让前景和背景的类间方差最大。在光照相对均匀的背光图里Otsu表现稳定基本不用手动调阈值。形态学操作用的是椭圆核而不是矩形核。因为虾苗轮廓偏圆润椭圆结构元素能更好地保留目标形状。开运算是先腐蚀后膨胀作用是去掉图像里孤立的小白点比如细碎杂质和传感器亮点。闭运算是先膨胀后腐蚀作用是把虾苗内部因为身体纹理造成的断裂区域重新连起来。我一般先开运算两次再闭运算三次具体次数看噪声程度调。3.3 粘连目标分割距离变换与分水岭形态学处理之后单个虾苗已经变成了一个干净白色区域。但虾苗喜欢扎堆总会有几条挨在一起在掩膜图上就是一个大块白斑。如果直接对白斑做轮廓计数粘连的几条会被算成一条这是误差的主要来源。解决办法是用“距离变换分水岭”把粘连目标切开。dist cv2.distanceTransform(closed, cv2.DIST_L2, 5) _, seed_thresh cv2.threshold(dist, 0.25 * dist.max(), 255, cv2.THRESH_BINARY) seeds np.uint8(seed_thresh) _, markers cv2.connectedComponents(seeds) markers markers 1 # 避免0和背景混淆 markers[closed 0] 0 # 背景区域标记为0交给分水岭 color_img img.copy() cv2.watershed(color_img, markers) segment_mask np.zeros_like(closed, dtypenp.uint8) for label in range(2, markers.max() 1): segment_mask[markers label] 255距离变换的原理是把每个白色前景像素到最近背景像素的距离算出来。虾苗中心位置离背景最远距离值就高边缘位置距离值低。这样粘连区域会形成几个“山峰”每个山峰对应一条虾苗的大致中心。0.25 * dist.max()的意思是只保留距离值达到最大距离四分之一以上的像素这些像素就是分水岭算法要找的种子点。分水岭算法把图像看成一个地形图种子点像落入低洼处的泉水水慢慢上涨当不同区域的水要汇合时就会设立分水岭边界。这个边界正好把粘连的虾苗区域切开。分水岭分割之后markers里每个正整数代表一个独立区域-1代表区域之间的分割线。我通过遍历标签区域把分割结果重新拼成一个干净的掩膜图。4. 计数实现与参数调优4.1 轮廓提取与面积筛选得到分割掩膜之后计数就变成了一件相对简单的事情提取轮廓画框统计数量。cnts, _ cv2.findContours(segment_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) result img.copy() count 0 for c in cnts: area cv2.contourArea(c) if area 20: # 过滤小噪点 continue x, y, w, h cv2.boundingRect(c) cv2.rectangle(result, (x, y), (x w, y h), (0, 255, 0), 2) count 1 print(count , count)这一段里最重要的参数是最小面积阈值。我的习惯是先统计所有轮廓的面积排个序看一眼直方图。背景噪声产生的轮廓通常面积很小比如几个像素到十几个像素虾苗的轮廓面积则大得多。但要注意虾苗规格不同面积差异也很大。比如体长1厘米的虾苗在1280x960图像里可能占200到300像素而体长0.5厘米的虾苗可能只有80到120像素。所以我不会一上来就写死一个魔法数字而是先在一批样本上统计面积分布再定。另外注意OpenCV版本的坑OpenCV 4的findContours返回两个值轮廓、层级OpenCV 3返回三个值图像、轮廓、层级。上面代码按OpenCV 4写如果是旧版本需要改成_, cnts, _ cv2.findContours(...)。4.2 核心参数调节经验表算法参数不多但每个参数都会影响结果。我把它们整理成一张表省得现场反复试参数位置范围建议调参说明高斯滤波核大小3x3至7x7噪点多用7x7虾须细用3x3一般5x5形态学核大小3x3至7x7取决于一尾虾苗在图像中的直径核大约为直径的1/10距离变换种子阈值0.1至0.3倍dist最大值粘连严重调低目标过大调高默认0.25最小面积阈值20至100像素按虾苗面积直方图统计后确定形态学迭代次数开运算1至3次闭运算2至4次噪声多则增加目标太细容易抹掉则减少调参原则是“先分割后计数”。如果计数偏少先看是不是粘连没切开如果计数偏多先看是不是一个虾苗被分成好几块。不要一上来就调最小面积阈值来硬凑结果。用标注图辅助观察比只看一个数字高效得多。4.3 批量计数与结果复核单张图跑通之后要做批量验证。真正的养殖现场一次可能要拍几十张图不能一张张手动处理。写一个批量遍历脚本输入文件夹输出每张图的计数结果和标注图。from pathlib import Path input_dir Path(images) output_dir Path(results) output_dir.mkdir(exist_okTrue) for path in sorted(input_dir.glob(*.jpg)): count, result_img count_shrimp(path) save_path output_dir / f{path.stem}_count{count}.jpg cv2.imwrite(str(save_path), result_img) print(f{path.name}: {count})批量跑完后我会把人工计数和算法计数一一对应记录下来。误差在一两个范围内可以接受但如果反复出现系统性偏差比如总是少数就要考虑是不是有一批粘连目标没切开或者有虾苗游出镜头边缘。5. 实际跑通的全流程脚本5.1 完整代码框架下面给一套可以直接跑通的全流程脚本把前面的预处理、分水岭、计数整合成一个函数。这里我把图像统一缩放到1280x960保证不同相机的分辨率不会导致参数失效。import cv2 import numpy as np from pathlib import Path def count_shrimp(image_path, min_area30, seed_ratio0.25): img cv2.imread(str(image_path)) if img is None: return 0, None # 统一处理尺寸避免不同相机分辨率导致参数失效 img cv2.resize(img, (1280, 960)) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations2) closed cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations3) dist cv2.distanceTransform(closed, cv2.DIST_L2, 5) _, seed_th cv2.threshold(dist, seed_ratio * dist.max(), 255, cv2.THRESH_BINARY) seeds np.uint8(seed_th) _, markers cv2.connectedComponents(seeds) markers markers 1 markers[closed 0] 0 color_img img.copy() cv2.watershed(color_img, markers) segment_mask np.zeros_like(closed, dtypenp.uint8) for label in range(2, markers.max() 1): segment_mask[markers label] 255 cnts, _ cv2.findContours(segment_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) count 0 for c in cnts: if cv2.contourArea(c) min_area: continue x, y, w, h cv2.boundingRect(c) cv2.rectangle(color_img, (x, y), (x w, y h), (0, 255, 0), 2) count 1 return count, color_img if __name__ __main__: input_dir Path(images) output_dir Path(results) output_dir.mkdir(exist_okTrue) for path in sorted(input_dir.glob(*.jpg)): count, result_img count_shrimp(path) if result_img is not None: cv2.imwrite(str(output_dir / f{path.stem}_count{count}.jpg), result_img) print(f{path.name}: {count})这套脚本不是万能的但应对“浅盘分散、背光拍摄、固定相机”的场景足够稳定。如果要部署到现场我还会加一个参数文件把最小面积、形态学核大小、种子阈值都写在配置文件里方便非技术人员调整。5.2 误差评估方法算法上线之前一定要做误差评估。我自己的标准流程是先从一批待测虾苗里随机取一盘让技术员人工数三遍取中位数作为真实数量。然后算法对这一盘重新搅拌、静置、拍摄五次得到五个算法结果取平均值。比如人工数是54尾算法五次输出分别是52、53、57、51、54平均数是53.4算下来误差是(53.4 - 54) / 54 -1.1%在可接受范围内。如果误差超过5%必须回去查图像而不是急着调参数。还要注意误差评估的样本要有代表性。不能只拿一盘最整齐的虾苗来测而是要从不同密度、不同规格、不同光照条件下各取几盘。这样得出的结论才靠谱。5.3 数据记录表我在现场习惯用这样一张表记录验证结果图像编号人工数尾算法数尾误差%备注015049-2.0轻微粘连0250512.0有一个气泡误判036360-4.8中部密度过高0432320分散良好0547470无明显干扰备注栏非常重要。它会告诉你误差不是随机出现的而是集中在“密度过高”或者“气泡干扰”的场景。有了记录后续改进方向就明确了。6. 常见问题与排查技巧实录6.1 高频问题速查表我在实际跑这个系统的过程中踩过不少坑也帮现场同事排查过各种奇怪现象。整理成一张速查表方便直接对着找问题。现象可能原因处理办法计数偏少虾苗粘连未切开调低距离变换种子阈值或增加闭运算次数计数偏多虾苗被分成多块调高最小面积阈值或减少形态学闭运算次数出现大量白色圆点气泡被当成虾苗静置更久让气泡破裂或根据圆度过滤局部区域过曝背景光源不均匀加扩散板调平背光避免环境光直射虾苗边缘发虚快门太慢或对焦不准提高快门速度固定相机高度调节镜头焦距每次结果波动大虾苗游动、水面晃动静置20秒以上多拍几张取平均值边缘出现半个目标虾苗贴着盘子边缘设置ROI只统计中间区域采样时避免虾苗靠边同一目标反复数出两个框一个虾苗被分水岭过度分割提高距离变换种子阈值检查形态学核是否过大6.2 我踩过的坑和对应的操作习惯第一个坑是玻璃容器。我一开始用了实验室常见的那种玻璃培养皿结果图像边缘全是反光虾苗折射出来的虚影比本体还多。换成白色搪瓷盘之后反光问题立刻少了大半。后来我在盘底垫了一张白色亚克力板再配合背光效果最好。所以容器材质比想象中重要。第二个坑是虾苗密度。第一次去现场时技术员一盘倒了好几百尾密密麻麻的分水岭算法再怎么调都分不开。后来把投苗量降下来控制在一盘一百五十尾以内浅盘加水让虾苗有足够活动空间准确率马上就上来了。记住算法再强也扛不住无限制的密度。第三个坑是手机拍照。项目初期图方便用手机架在支架上拍闪光灯自动打开高光区域直接把虾苗细节淹没。而且手机相机自动处理图像白平衡和锐化经常变化算法很难稳定。换成固定参数设置的USB摄像头后图像质量稳定调参才变得有意义。第四个坑是距离变换阈值写死。我最初为了省事直接把种子阈值固定成一个数比如20像素。换了另一批虾苗或者调了相机高度后计数误差突然变大。后来改成按dist.max()的百分比取阈值适应不同规格的虾苗问题就消失了。这种参数尽量用归一化的形式而不是绝对数值。最后想提醒一句现场环境和实验室不一样背光板用久了会积灰盘子上有水渍相机镜头会被溅到。每次开始批量计数前先拍一张空白图看一眼背景是否干净。这个习惯能帮你省掉很多排查时间。OpenCV虾苗计数这件事做到最后拼的不是算法多炫而是把每个环节都控制住稳定的光源、规范的采样、合理的分割参数、可追溯的误差记录。只要这条路走通它就能从实验室搬到实际生产线上每天省下好几个小时的人工点数时间。