数虾苗神器:纯 Java 图像处理 + AI 大模型混合计数算法详解
数虾苗神器:纯 Java 图像处理 + AI 大模型混合计数算法详解
前言
在水产养殖中,虾苗计数是一项高频刚需——养殖户每天需要对数以万计的虾苗进行精确计数。传统的人工一只只数效率极低,而市面上基于深度学习的方案往往需要昂贵的 GPU 和大量标注数据。
我开发的「数虾苗神器」微信小程序采用了一套纯 Java 传统图像处理 + AI 视觉大模型辅助的混合计数方案。核心计数器是确定性 CV 算法(零 API 成本),只在稀疏边缘区域调用 VLM 做补充。整套图像处理算法不依赖 OpenCV,纯 java 实现,Docker 镜像体积仅 ~200MB。
本文将深入剖析这套混合计数系统的算法原理、实现细节和工程实践。
一、系统架构总览
用户拍摄上传 → ROI 盆区域检测 → 4×4 分格 → 逐格分流
├─ 内部格(ROI≥95%) → CV 局部最大值 → × 校正系数
└─ 边缘格(ROI<95%) → Qwen-VL 大模型辅助
↓
汇总 = 最终计数
核心设计理念:
- CV 是主力计数器:覆盖 85%+ 的虾苗,误差 ~6%(系统性、可校正),零 API 成本
- VLM 仅做边缘补充:仅处理密度低、ROI 不完整的边缘格,占比小(~13%),精度要求低
- 确定性算法优先:CV 误差是系统性的(该漏检/多检的地方每次都一样),通过 2-3 个格子的 Ground Truth 即可算校正系数修正全图
二、图像预处理:ROI 盆区域检测
2.1 为什么需要 ROI 检测
虽然我们有专用拍摄箱体(白色哑光盘面 + 固定光源),但照片的边角区域仍可能包含桌面、阴影或盆壁。这些区域的暗色像素会被误判为虾苗,产生大量假阳性。
ROI 检测的核心目标:在全图中自动识别白色盘面区域,生成盆内 mask,将盆外像素全部排除。
2.2 OTSU 大津法 + BFS 盆地检测
算法流程:
原图 → 大半径 Box Blur 消除虾苗 → OTSU 二值化(亮=盆,暗=背景)
→ BFS 从中心找最大亮区 = 盆区域
→ 多次膨胀覆盖盆壁边缘虾苗
第一步:大半径 Box Blur 消除虾苗
虾苗在图像中表现为约 5-15px 的深色斑点。用 min(W,H)/30 的大半径模糊核卷积后,虾苗个体被完全"抹平",只剩下盆(亮色)和背景(暗色)的大尺度区域。
int blurR = Math.max(5, Math.min(W, H) / 30);
int[] heavy = boxBlur(gray, W, H, blurR);
Box Blur 使用滑动窗口实现,水平+垂直各一次,时间复杂度 O(W×H),无需 FFT。
第二步:OTSU 自适应二值化
OTSU(大津法)遍历 0-255 共 256 个灰度级,找到使类间方差最大的阈值 T:
- 类 0(背景/桌面):灰度 < T
- 类 1(前景/盆面):灰度 ≥ T
目标函数最大化:σ² = w₀ × w₁ × (μ₀ - μ₁)²
为防止阈值过高漏掉盆边缘暗区,实际阈值取 OTSU 结果的 85%:
int otsu = otsuThreshold(heavy, total);
otsu = (int) (otsu * 0.85); // 降低15%,包容边缘暗区
第三步:BFS 从中心找盆区域
从图像中心出发,BFS 8-邻域搜索所有高于阈值的连通像素。因为盆位于画面中央且面积最大,BFS 自然找到完整的盆区域。
// 从中心出发找最近的亮像素做种子
int seedX = W / 2, seedY = H / 2;
// BFS 填充
while (head < tail) {
int x = qx[head], y = qy[head++];
for (int[] d : DIRS8) {
if (/* 在范围内 && 未访问 && 灰度 >= OTSU */) {
basin[ny * W + nx] = true;
qx[tail] = nx; qy[tail++] = ny;
}
}
}
第四步:膨胀覆盖盆壁边缘
盆壁边缘的虾苗可能因光照稍暗而被排除在盆地 mask 外。通过 8 次 3×3 膨胀操作向外扩展 8px 左右,确保边缘虾苗也被纳入 ROI:
for (int iter = 0; iter < 8; iter++) {
// 每个非 ROI 像素,若 8 邻域有 ROI 像素则纳入
}
异常回退:若盆地占比 < 3%(几乎全黑)或 > 88%(几乎全白),判定为检测失败,回退到全图模式。
2.3 效果验证
以测试图片 65_286(~4800 只虾苗)为例:
- 内部格 ROI 覆盖 100%,CV 计数准确
- 边缘/角落格 ROI 覆盖 11-81%,自然识别盆边界
- ROI mask 大小仅 ~2KB,无额外存储或 API 成本
三、核心计数算法:局部最大值法
这是整个系统最关键的部分。我们对比了多种方案后选择了背景减法 + 局部最大值的方案。
3.1 方案对比
| 方法 | 原理 | 密集场景误差 | 气泡误检 | 速度 |
|---|---|---|---|---|
| 自适应阈值 + 连通域 | 二值化 → BFS blob → 面积过滤 | -16.5%(粘连合并) | 中 | 快 |
| 自适应阈值 + Watershed | 连通域 + 距离变换分割 | -5.2%(分割不完全) | 中 | 较慢 |
| 背景减法 + 局部最大值 | 背景-原图 → 找暗峰 | ~6% | 低 | 快 |
局部最大值法有两个天然优势:
- 密集虾苗自然分离:每只虾苗的头部/身体中心是一个独立的暗峰,即使两只虾贴近,只要暗峰可分辨就能分别检出。不需要 Watershed 的距离变换+分水岭分割
- 气泡自然被过滤:气泡是圆形透明的,detail 值远低于虾苗实体。threshold=6.0 直接过滤
3.2 算法原理
核心思想:虾苗在白色盘面上表现为局部暗区,每只虾苗的头部或身体中心是一个暗度峰值。
Step 1: 大模糊估算局部背景
background = boxBlur(gray, blurR) // blurR ≈ min(W,H)/30
Step 2: 增强暗纹
detail = background - gray // 暗处 detail 高
Step 3: 轻平滑去噪
detail = gaussianBlur1D(detail, sigma=0.75)
Step 4: 找局部最大值
localMax = 在每个 windowSize×windowSize 邻域内 detail 最大
Step 5: 过滤
保留 ROI 内 && detail ≥ threshold 的点
为什么用"背景 - 原图"而非直接阈值?
直接阈值容易受光照不均影响——盆左侧可能整体暗一些,右侧整体亮一些,固定阈值要么漏检左侧要么在右侧产生噪声。"背景-原图"先估算局部光照水平,再取相对暗度,天然适应光照渐变。这是类同于 CLAHE(自适应直方图均衡化)的思想,但实现简单得多。
3.3 参数调优
我在多张真实虾苗照片上做了系统的参数扫描:
| 窗口大小 | 暗度阈值 | 检测数 | 评价 |
|---|---|---|---|
| 5×5 | 4.5 | 6093 | 少量噪声点 |
| 5×5 | 6.0 | 6003 | ✅ 平衡最佳 |
| 5×5 | 7.5 | 5937 | 稍有漏检 |
| 7×7 | 6.0 | 5571 | 密集虾苗合并 |
| 9×9 | 9.0 | 5080 | 漏检严重 |
结论:
- 窗口 5×5:窗口太大(7×7, 9×9)会把相邻虾苗的暗峰合并,漏检明显
- 阈值 6.0:最佳平衡点,能检测出真正的虾苗暗峰,同时过滤气泡和噪声
这些参数都已配置化在 CountingProperties 中,支持通过 application.yml 按场景调整。
3.4 代码实现要点
Box Blur 滑动窗口优化(O(W×H)):
int[] boxBlur(int[] src, int W, int H, int r) {
// 水平方向:滑动窗口维护 sum 和 cnt
for (int y = 0; y < H; y++) {
int sum = 0, cnt = 0;
for (int x = 0; x < Math.min(r + 1, W); x++) { sum += src[y * W + x]; cnt++; }
for (int x = 0; x < W; x++) {
tmp[y * W + x] = sum / cnt;
if (x + r + 1 < W) { sum += src[y * W + x + r + 1]; cnt++; }
if (x - r >= 0) { sum -= src[y * W + x - r]; cnt--; }
}
}
// 垂直方向同理
}
局部最大值检测(原生实现,不依赖 ndimage.maximum_filter):
boolean[] findLocalMaxima(float[] data, int W, int H, int windowSize) {
int half = windowSize / 2;
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
float val = data[y * W + x];
boolean isMax = true;
// 在 windowSize×windowSize 邻域内检查
for (int ny = y - half; ny <= y + half && isMax; ny++)
for (int nx = x - half; nx <= x + half && isMax; nx++)
if (data[ny * W + nx] > val) isMax = false;
maxima[y * W + x] = isMax;
}
}
return maxima;
}
四、CV 校正系数机制
4.1 为什么校正有效
CV 算法的误差是系统性的而非随机的——同一个虾苗在同一个位置,该漏检/多检的结果每次都一样。这意味着:
- 取 2-3 个内部格子,人工点标注获得 Ground Truth
- 计算
校正系数 = ΣGT / ΣCV - 用该系数修正所有内部格子的 CV 计数
实测数据验证(65_286,~4800 只虾苗):
| 区域 | GT 样本 | GT 合计 | CV 合计 | 校正系数 | 误差 |
|---|---|---|---|---|---|
| 内部格 | R2C1, R2C2 | 1,350 | 1,428 | 0.9454 | 5.8% |
只用 2 个格子的 GT(约占全图 10-20% 面积),就将全图误差纠正到 5.8%。
4.2 校正系数的稳定性
只要拍照条件不变(同一箱体、同一光源、同一拍摄高度),校正系数可以跨图复用。更换硬件或光源后只需重新标 2-3 个格子更新。
这点非常关键——标注工作量极低。不需要像深度学习那样标注成千上万个样本,每批照片只需 2-3 个格子的人工标注,即可持续产出高精度结果。
五、VLM 辅助:边缘格的大模型计数
5.1 边缘格为什么不用 CV
角落和边缘的格子 ROI 覆盖率低(11-81%),这些区域盆壁、阴影、反光干扰多。CV 在这些格子上的误差波动大且不可控。
但边缘格虾苗密度通常较低(< 50 只/格),恰好是 VLM 能处理的场景。
5.2 VLM 调用策略
当前使用阿里云百炼 DashScope 的 Qwen-VL-Plus 模型,仅对边缘格调用:
- 4×4 = 16 格,内部格(ROI≥95%)约 8-10 格走 CV,不调 API
- 边缘格约 6-8 格走 VLM,每次调用前降分辨率至 1024 像素
- EDGE 中已有 GT 样本时直接用 GT,减少 API 调用
单图 API 调用量从旧方案的 9 次(3×3 全部走 VLM)降至 6-8 次,成本约 0.01 元/图。
5.3 关键教训:VLM 不擅长精确计数
实测发现 Qwen-VL-Plus 在单格 > 50 只时就输出随机数字(389 → 1234,961 → 12345),Qwen-VL-Max 甚至更差。
这不是提示词的问题,是 VLM 的本质限制——视觉大模型擅长语义理解和场景描述,但精确计数(尤其是密集小目标)是其弱项。
这也正是我们把架构从"VLM 主力计数"改为"CV 主力 + VLM 辅助"的根本原因。
六、标注图生成
标注图是用户看到的关键输出——在虾苗原图上叠加绿点标注 + 网格虚线 + 计数数字,直观展示识别效果。
6.1 绿点绘制
g2.setColor(new Color(0, 220, 80)); // 鲜绿色
for (int[] pt : allCentroids) {
g2.fillOval(pt[0] - dotR, pt[1] - dotR, dotR * 2, dotR * 2);
}
每个检测到的虾苗中心绘制半径 3px 的绿色实心圆。
6.2 网格和计数标注
// 蓝色虚线网格
float[] dashPattern = {10f, 8f};
Stroke dashed = new BasicStroke(2.5f, ..., dashPattern, 0f);
g2.setColor(new Color(80, 80, 220, 180));
// 每个格子左上角:行-列:计数
// 如 "2-3:257" 表示第2行第3列检测到 257 只
g2.setColor(Color.WHITE);
g2.drawString("2-3:257", x + fontSize/3, y + fontSize + fontSize/2);
// 右下角:Total 总数
g2.setColor(new Color(0, 220, 80));
g2.drawString("Total: 4583", textX, textY);
6.3 为什么绿点和数字来自同一算法
这保证了所见即所得——用户看到的绿点数量 = 系统报出的数字。不会出现"绿点看起来很多但显示的数字很少"的信任危机。
七、多图均值与业务校验
7.1 同一任务多图采样
鼓励用户对同一批虾苗拍摄 2-3 张(从不同角度或稍微拨散虾苗后重拍),系统取均值作为最终建议数:
double mean = validCounts.stream().mapToInt(Integer::intValue).average().orElse(0);
aiSuggest = (int) Math.round(mean);
同时计算标准差,量化多张图片之间的识别一致性。
7.2 异常值检测(计划中)
| 规则 | 触发条件 | 处理 |
|---|---|---|
| 单图异常高 | 某图 > 任务平均 × 2 | 提示重拍该图 |
| 图片间差异大 | max/min > 3 | 提示检查是否重复/漏拍 |
| 空盘检测 | CV < 3 | 提示"未检测到虾苗" |
| 模糊检测 | 拉普拉斯方差 < 阈值 | 提示重拍 |
7.3 用户确认闭环
- AI 输出"建议数量",用户可手动修正后确认
- 用户修正量 > 20% → 自动标记为"误差样本",上传原图用于后续分析
- 形成「采集 → 修正 → 标注 → 更新校正系数」的数据闭环
八、性能与成本
8.1 单图处理耗时
| 环节 | 耗时 | 备注 |
|---|---|---|
| ROI 检测 | ~50ms | O(W×H),Java 原生 |
| 4×4 格 CV 计数 | ~200ms | 16 格并行串行均可 |
| 标注图生成 | ~100ms | 绿点绘制 + 网格 |
| VLM 边缘格调用 | ~3-5s | 6-8 次异步并发 |
| 总计 | ~4-6s |
8.2 API 成本分析
| 场景 | VLM 调用 | 单图成本 | 月成本(20张/用户) |
|---|---|---|---|
| 旧方案(全 VLM) | 9次 | ~0.015元 | ~0.30元 |
| 新方案(CV主力) | 6-8次 | ~0.01元 | ~0.20元 |
| 全CV(目标) | 0次 | 0元 | 0元 |
未来目标:边缘格也走 CV,通过积累更精确的边缘 ROI 校正模型,实现纯 CV 零成本计图。
九、为什么不用深度学习
这个问题经常被问到。简单说:
- 无 GPU 依赖:纯
java实现,在任何 JVM 上都能跑,Docker 镜像仅 200MB - 无样本需求:不需要标注成千上万张图片,只需 2-3 个格子的 GT 校正
- 确定性输出:相同输入始终产出相同结果,方便排查问题
- 硬件条件配合好:专用拍摄箱体(白色哑光盘 + 固定光源 + 固定距离)为传统 CV 提供了理想工况
当然,VLM 部分我们用了 Qwen-VL,但它被限定在稀疏边缘格——这些格密度低,大模型的"模糊语义理解"能力恰好够用。
十、工程亮点总结
- 纯 Java 图像处理全链路:ROI检测(OTSU+BFS)、Box Blur(滑动窗口)、局部最大值检测、标注图生成,全部
java实现,零外部依赖 - CV 主力 + VLM 辅助:确定性算法负责密集核心区域(零成本+可控误差),概率性模型仅负责稀疏边缘(低精度要求+低成本)
- 校正系数机制:利用 CV 误差的系统性,2-3 格 GT 即可修正全图,标注成本极低
- 硬件+算法协同设计:拍摄箱体消除环境变量(光线/角度/背景),让算法专注于核心计数问题
- 参数可配置化:窗口大小、暗度阈值、校正系数等关键参数通过
application.yml配置,支持不同场景快速调优
技术栈:Java 17 / Spring Boot 3.2 / 阿里云百炼 DashScope Qwen-VL / 微信小程序 / PostgreSQL
本文基于「数虾苗神器」v2.0 版本,算法持续迭代中。如有更好的计数方案,欢迎交流!
更多推荐




所有评论(0)