WellDetector.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. using System;
  2. using System.Collections.Generic;
  3. namespace AutoFocusTool.Imaging
  4. {
  5. /// <summary>well 圆检测结果。</summary>
  6. public class WellCircle
  7. {
  8. public double Cx, Cy;
  9. public double Radius;
  10. public double FillRatio; // 连通域面积 / 外接圆面积,越接近1越圆
  11. public bool Found;
  12. public double OffsetX, OffsetY;
  13. public double OffsetXPct, OffsetYPct;
  14. public bool Complete; // well 完整在画面内
  15. public double AreaPct; // 连通域占全图比例
  16. public double Aspect, BoxFill, RConsist; // 调试:宽高比/外接框填充/半径一致性
  17. public string RejectReason = "";
  18. public override string ToString()
  19. => Found
  20. ? $"圆心({Cx:F0},{Cy:F0}) R={Radius:F0} 偏移X={OffsetXPct:F1}% Y={OffsetYPct:F1}% 完整={Complete} 圆度={FillRatio:F2} 面积={AreaPct:F1}%"
  21. : "未检出well圆";
  22. }
  23. /// <summary>
  24. /// well 大圆检测(纯C#,无OpenCV)。
  25. /// well 在背光下是一片中灰亮盘。方法:Otsu阈值 → 最大连通域(BFS) →
  26. /// 由连通域的质心/面积/外接框算圆心半径,并用圆度+最小尺寸过滤,拒绝噪声小亮斑。
  27. /// 关键改进:用"最大连通域"而非"全部亮像素质心",避免分散反光/小亮斑把圆心带偏。
  28. /// </summary>
  29. public static class WellDetector
  30. {
  31. /// <summary>检出阈值:连通域至少占全图比例、最小半径(下采样像素)、最低圆度。</summary>
  32. public static double MinAreaPct = 4.0;
  33. public static double MinFillRatio = 0.55;
  34. public static WellCircle Detect(byte[] bgr24, int width, int height, int downsample = 4)
  35. {
  36. int dw = width / downsample, dh = height / downsample;
  37. var gray = new byte[dw * dh];
  38. int stride = width * 3;
  39. int[] hist = new int[256];
  40. for (int y = 0; y < dh; y++)
  41. {
  42. int sy = y * downsample;
  43. for (int x = 0; x < dw; x++)
  44. {
  45. int p = sy * stride + (x * downsample) * 3;
  46. byte b = bgr24[p], g = bgr24[p + 1], r = bgr24[p + 2];
  47. byte gg = (byte)((b * 29 + g * 150 + r * 77) >> 8);
  48. gray[y * dw + x] = gg;
  49. hist[gg]++;
  50. }
  51. }
  52. int th = Otsu(hist, dw * dh);
  53. var res = new WellCircle();
  54. // 最大连通域(BFS, 4邻域)
  55. var label = new int[dw * dh];
  56. int bestCnt = 0, bestX0 = 0, bestY0 = 0, bestX1 = 0, bestY1 = 0;
  57. long bestSx = 0, bestSy = 0;
  58. var queue = new Queue<int>();
  59. for (int idx = 0; idx < dw * dh; idx++)
  60. {
  61. if (gray[idx] < th || label[idx] != 0) continue;
  62. // BFS
  63. int cnt = 0; long sx = 0, sy = 0;
  64. int minx = dw, miny = dh, maxx = 0, maxy = 0;
  65. label[idx] = 1; queue.Enqueue(idx);
  66. while (queue.Count > 0)
  67. {
  68. int q = queue.Dequeue();
  69. int qx = q % dw, qy = q / dw;
  70. cnt++; sx += qx; sy += qy;
  71. if (qx < minx) minx = qx; if (qx > maxx) maxx = qx;
  72. if (qy < miny) miny = qy; if (qy > maxy) maxy = qy;
  73. if (qx > 0 && gray[q - 1] >= th && label[q - 1] == 0) { label[q - 1] = 1; queue.Enqueue(q - 1); }
  74. if (qx < dw - 1 && gray[q + 1] >= th && label[q + 1] == 0) { label[q + 1] = 1; queue.Enqueue(q + 1); }
  75. if (qy > 0 && gray[q - dw] >= th && label[q - dw] == 0) { label[q - dw] = 1; queue.Enqueue(q - dw); }
  76. if (qy < dh - 1 && gray[q + dw] >= th && label[q + dw] == 0) { label[q + dw] = 1; queue.Enqueue(q + dw); }
  77. }
  78. if (cnt > bestCnt) { bestCnt = cnt; bestSx = sx; bestSy = sy; bestX0 = minx; bestY0 = miny; bestX1 = maxx; bestY1 = maxy; }
  79. }
  80. res.AreaPct = 100.0 * bestCnt / (dw * dh);
  81. if (bestCnt < dw * dh * MinAreaPct / 100.0) { res.Found = false; return res; }
  82. double cxd = (double)bestSx / bestCnt, cyd = (double)bestSy / bestCnt;
  83. int bw = bestX1 - bestX0 + 1, bh = bestY1 - bestY0 + 1;
  84. // 半径:外接框平均半宽 与 面积反推半径
  85. double rBox = (bw + bh) / 4.0;
  86. double rArea = Math.Sqrt(bestCnt / Math.PI);
  87. double rd = (rBox + rArea) / 2;
  88. // ── 排除斜纹反光带的双判据 ──
  89. // 1) 外接框宽高比:真well≈1(圆),反光带细长偏离1
  90. double aspect = (double)Math.Max(bw, bh) / Math.Max(1, Math.Min(bw, bh));
  91. // 2) 圆度:连通域面积 / 外接框面积。实心圆≈π/4≈0.785;反光带稀疏远小于此
  92. double boxFill = (double)bestCnt / (bw * bh);
  93. // 3) 面积半径与外接框半径一致性:真圆两者接近,反光带差很多
  94. double rConsist = Math.Min(rArea, rBox) / Math.Max(rArea, rBox);
  95. res.FillRatio = boxFill; // 用外接框填充率作圆度指标
  96. res.Aspect = aspect; res.BoxFill = boxFill; res.RConsist = rConsist;
  97. // 半径合理范围:真well半径约占画面宽15~26%(实测~490/2592=19%)。
  98. // 斜纹反光带半径常达34%(~870),用上限拦掉;过小的也排除。
  99. double rFrac = rd * downsample / width;
  100. // 真well判据:宽高比<1.5(够方) + 外接框填充>0.6(够实心) + 半径一致>0.65 + 半径占比在[0.10,0.28]
  101. if (aspect > 1.5 || boxFill < 0.6 || rConsist < 0.65 || rFrac > 0.28 || rFrac < 0.10)
  102. {
  103. res.Found = false;
  104. res.RejectReason = $"aspect={aspect:F2} boxFill={boxFill:F2} rConsist={rConsist:F2} rFrac={rFrac:F2}";
  105. return res;
  106. }
  107. res.Cx = cxd * downsample;
  108. res.Cy = cyd * downsample;
  109. res.Radius = rd * downsample;
  110. res.Found = true;
  111. res.OffsetX = res.Cx - width / 2.0;
  112. res.OffsetY = res.Cy - height / 2.0;
  113. res.OffsetXPct = res.OffsetX / width * 100;
  114. res.OffsetYPct = res.OffsetY / height * 100;
  115. // 完整性:外接框是否触画面边(下采样坐标)
  116. res.Complete = bestX0 > 1 && bestY0 > 1 && bestX1 < dw - 2 && bestY1 < dh - 2;
  117. return res;
  118. }
  119. private static int Otsu(int[] hist, int total)
  120. {
  121. long sum = 0;
  122. for (int i = 0; i < 256; i++) sum += (long)i * hist[i];
  123. long sumB = 0; int wB = 0; double maxVar = 0; int th = 0;
  124. for (int t = 0; t < 256; t++)
  125. {
  126. wB += hist[t];
  127. if (wB == 0) continue;
  128. int wF = total - wB;
  129. if (wF == 0) break;
  130. sumB += (long)t * hist[t];
  131. double mB = (double)sumB / wB;
  132. double mF = (double)(sum - sumB) / wF;
  133. double between = (double)wB * wF * (mB - mF) * (mB - mF);
  134. if (between > maxVar) { maxVar = between; th = t; }
  135. }
  136. return th;
  137. }
  138. }
  139. }