数虾苗神器:纯 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%

局部最大值法有两个天然优势:

  1. 密集虾苗自然分离:每只虾苗的头部/身体中心是一个独立的暗峰,即使两只虾贴近,只要暗峰可分辨就能分别检出。不需要 Watershed 的距离变换+分水岭分割
  2. 气泡自然被过滤:气泡是圆形透明的,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 算法的误差是系统性的而非随机的——同一个虾苗在同一个位置,该漏检/多检的结果每次都一样。这意味着:

  1. 取 2-3 个内部格子,人工点标注获得 Ground Truth
  2. 计算 校正系数 = ΣGT / ΣCV
  3. 用该系数修正所有内部格子的 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 零成本计图

九、为什么不用深度学习

这个问题经常被问到。简单说:

  1. 无 GPU 依赖:纯 java 实现,在任何 JVM 上都能跑,Docker 镜像仅 200MB
  2. 无样本需求:不需要标注成千上万张图片,只需 2-3 个格子的 GT 校正
  3. 确定性输出:相同输入始终产出相同结果,方便排查问题
  4. 硬件条件配合好:专用拍摄箱体(白色哑光盘 + 固定光源 + 固定距离)为传统 CV 提供了理想工况

当然,VLM 部分我们用了 Qwen-VL,但它被限定在稀疏边缘格——这些格密度低,大模型的"模糊语义理解"能力恰好够用。

十、工程亮点总结

  1. 纯 Java 图像处理全链路:ROI检测(OTSU+BFS)、Box Blur(滑动窗口)、局部最大值检测、标注图生成,全部 java 实现,零外部依赖
  2. CV 主力 + VLM 辅助:确定性算法负责密集核心区域(零成本+可控误差),概率性模型仅负责稀疏边缘(低精度要求+低成本)
  3. 校正系数机制:利用 CV 误差的系统性,2-3 格 GT 即可修正全图,标注成本极低
  4. 硬件+算法协同设计:拍摄箱体消除环境变量(光线/角度/背景),让算法专注于核心计数问题
  5. 参数可配置化:窗口大小、暗度阈值、校正系数等关键参数通过 application.yml 配置,支持不同场景快速调优

技术栈:Java 17 / Spring Boot 3.2 / 阿里云百炼 DashScope Qwen-VL / 微信小程序 / PostgreSQL

本文基于「数虾苗神器」v2.0 版本,算法持续迭代中。如有更好的计数方案,欢迎交流!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐