CalibrationEngine.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Threading;
  5. using AutoFocusTool.Serial;
  6. using AutoFocusTool.Imaging;
  7. using SerialCamera = AutoFocusTool.Camera.Camera;
  8. namespace AutoFocusTool.Calib
  9. {
  10. /// <summary>
  11. /// 自动标定引擎(可复用,GUI/命令行共用)。封装单well标定四步:
  12. /// ①粗对焦(中央40% ROI) → ②水平扫描找最居中位置 → ③曝光二分(well内ROI) → ④精对焦(0.95r ROI + 平滑插值)
  13. /// 通过回调上报进度(文字)与实时帧(byte[]),便于界面实时显示。
  14. /// </summary>
  15. public class CalibrationEngine
  16. {
  17. readonly int W, H; // 取自相机实际分辨率,绝不能硬编码
  18. readonly HouseMotor _motor;
  19. readonly SerialCamera _cam;
  20. public Action<string> Log;
  21. public Action<byte[]> OnFrame; // 实时帧回调(原始BGR)
  22. public Action<string, WellCircle, ExposureInfo> OnStep; // 步骤+当前圆/曝光
  23. /// <summary>居中容差(Y偏移百分比),落入即合格,避免来回震荡。</summary>
  24. public double CenterTolPct = 12;
  25. /// <summary>粗扫范围(脉冲,零点±range)与步数。</summary>
  26. public int HScanRange = 4000;
  27. public int HScanSteps = 7;
  28. /// <summary>细扫步数(在粗扫最佳点附近精调Y)。</summary>
  29. public int FineScanSteps = 7;
  30. /// <summary>Z扫描半幅与层数。</summary>
  31. public int ZHalf = 1500, ZLayers = 9;
  32. public int ExpLo = 10, ExpHi = 800;
  33. /// <summary>扫描时电机稳定延时(ms)。小步移动无需1500ms,用短延时大幅提速。</summary>
  34. public int ScanDelayMs = 350;
  35. /// <summary>粗对焦层数(先让well清晰,再居中)。</summary>
  36. public int CoarseFocusLayers = 7;
  37. /// <summary>居中扫描用的固定曝光(well是暗背景上中灰盘,HScan实测~60可清晰检圆)。</summary>
  38. public Action<byte[], string> DebugSave; // 诊断存图(帧,文件名前缀),可选
  39. public int CenterScanExposure = 60;
  40. // ── 行程限位(所有电机移动前钳到该区间)──
  41. /// <summary>水平电机行程下/上限脉冲(旧工程自检值 70000)。</summary>
  42. public int HMin = 0, HMax = 70000;
  43. /// <summary>垂直电机行程下/上限脉冲(旧工程软上限 125000)。</summary>
  44. public int ZMin = 0, ZMaxPulse = 125000;
  45. // ── 水平全行程粗扫定位 ──
  46. /// <summary>水平全行程粗扫起点/终点/步距。命中完整圆即停。</summary>
  47. public int HCoarseStart = 0;
  48. public int HCoarseEnd = 70000;
  49. public int HCoarseStep = 2000;
  50. // ── Z 全范围粗对焦(固定中心大窗口)──
  51. /// <summary>Z 粗对焦固定中心(实测焦面集中区间 60000~120000 的中点)。</summary>
  52. public int ZCoarseCenter = 90000;
  53. /// <summary>Z 粗对焦半幅 → 区间 60000~120000。</summary>
  54. public int ZCoarseHalf = 30000;
  55. /// <summary>Z 粗对焦步距 → 约 31 层。</summary>
  56. public int ZCoarseStep = 2000;
  57. // ── Z 精对焦(围绕粗峰)──
  58. /// <summary>精对焦半幅(覆盖粗扫 ±2000 峰定位误差并留余量)。</summary>
  59. public int FineZHalf = 6000;
  60. /// <summary>精对焦步距 → 约 24 层(精度优先)。</summary>
  61. public int FineZStep = 500;
  62. public CalibrationEngine(HouseMotor motor, SerialCamera cam)
  63. {
  64. _motor = motor; _cam = cam;
  65. W = cam.Width; H = cam.Height;
  66. }
  67. /// <summary>水平脉冲钳到 [HMin,HMax],越界写 Log。</summary>
  68. int ClampH(int p)
  69. {
  70. int c = Math.Max(HMin, Math.Min(HMax, p));
  71. if (c != p) Log?.Invoke($" ⚠ 水平脉冲 {p} 越界,钳到 {c} [{HMin},{HMax}]");
  72. return c;
  73. }
  74. /// <summary>垂直脉冲钳到 [ZMin,ZMaxPulse],越界写 Log。</summary>
  75. int ClampZ(int p)
  76. {
  77. int c = Math.Max(ZMin, Math.Min(ZMaxPulse, p));
  78. if (c != p) Log?.Invoke($" ⚠ 垂直脉冲 {p} 越界,钳到 {c} [{ZMin},{ZMaxPulse}]");
  79. return c;
  80. }
  81. /// <summary>中央40% ROI(精对焦/粗对焦圆未检出时的降级,避免背景边缘带偏)。</summary>
  82. System.Drawing.Rectangle CenterRoi40()
  83. {
  84. int roiW = (int)(W * 0.4), roiH = (int)(H * 0.4);
  85. return new System.Drawing.Rectangle((W - roiW) / 2, (H - roiH) / 2, roiW, roiH);
  86. }
  87. /// <summary>
  88. /// P0-6: 关键定位移动带重试。下位机偶发不回复(串口噪声/忙)时直接放弃整个well过于脆弱,
  89. /// 重试最多 maxRetry 次,每次间隔后再试。
  90. /// </summary>
  91. bool RetryMove(Func<bool> move, string what, int maxRetry = 3)
  92. {
  93. for (int i = 0; i < maxRetry; i++)
  94. {
  95. if (move()) return true;
  96. Log?.Invoke($" {what} 移动失败,重试 {i + 1}/{maxRetry}");
  97. Thread.Sleep(400);
  98. }
  99. return false;
  100. }
  101. byte[] Grab()
  102. {
  103. int retry = 0;
  104. while (retry < 3)
  105. {
  106. try
  107. {
  108. _cam.GrabRgb();
  109. var b = _cam.GetSourceBuffer();
  110. if (b != null && b.Length > 0)
  111. {
  112. OnFrame?.Invoke(b);
  113. return b;
  114. }
  115. }
  116. catch (Exception ex)
  117. {
  118. Log?.Invoke($"抓帧失败(重试{retry + 1}/3): {ex.Message}");
  119. }
  120. retry++;
  121. Thread.Sleep(50);
  122. }
  123. throw new Exception("抓帧失败,已重试3次");
  124. }
  125. /// <summary>
  126. /// 绕 center±range 扫 steps 个水平位置,找 well 检出且 |Y偏移| 最小的位置。
  127. /// 转动培养皿让 well 在画面上下(Y)移动,故按 Y 偏移评分;优先完整。
  128. /// </summary>
  129. (int bestHPos, WellCircle bestCircle) ScanForCenter(int well, int center, int range, int steps, int delayMs = -1)
  130. {
  131. int bestHPos = center; double bestScore = double.MaxValue; WellCircle best = null;
  132. int step = steps > 1 ? 2 * range / (steps - 1) : 1;
  133. int actualDelay = delayMs > 0 ? delayMs : ScanDelayMs; // 使用传入延时或默认值
  134. // 固定中低曝光设一次即可:well 是暗背景上的中灰盘,不能按全图均值自动曝光(会过曝well盘)
  135. _cam.SetExposure(CenterScanExposure);
  136. for (int i = 0; i < steps; i++)
  137. {
  138. int hp = ClampH(center - range + step * i);
  139. _motor.HorizontalMoveTo(hp, actualDelay);
  140. var b = Grab();
  141. var c = WellDetector.Detect(b, W, H);
  142. DebugSave?.Invoke(b, $"center_w{well}_hp{hp}");
  143. Log?.Invoke(c.Found
  144. ? $" 水平{hp}: Y偏移={c.OffsetYPct:F1}% X={c.OffsetXPct:F1}% 完整={c.Complete}"
  145. : $" 水平{hp}: 未检出圆");
  146. OnStep?.Invoke($"居中扫描 hp={hp}", c, null);
  147. if (c.Found)
  148. {
  149. // 按 |Y偏移| 评分,不完整重罚
  150. double score = Math.Abs(c.OffsetYPct) + (c.Complete ? 0 : 100);
  151. if (score < bestScore) { bestScore = score; bestHPos = hp; best = c; }
  152. }
  153. }
  154. return (bestHPos, best);
  155. }
  156. /// <summary>
  157. /// 水平全行程粗扫定位 + 局部密扫居中。不依赖 EEPROM 水平位置准确:
  158. /// ① 从 HCoarseStart 到 HCoarseEnd 按 HCoarseStep 扫,命中完整圆即停,记录该位置;
  159. /// ② 以命中点为中心做局部密扫(ScanForCenter)优化 Y 居中;
  160. /// ③ 全程未命中完整圆则取扫描中 |Y偏移| 最小且检出的位置;仍无返回 (-1,null)。
  161. /// </summary>
  162. (int bestHPos, WellCircle bestCircle) HCoarseLocate(int well)
  163. {
  164. _cam.SetExposure(CenterScanExposure);
  165. int hitHPos = -1; WellCircle hitCircle = null;
  166. int fallbackHPos = -1; WellCircle fallbackCircle = null;
  167. double fallbackScore = double.MaxValue;
  168. // ① 全行程粗扫,命中完整圆即停
  169. for (int hp = HCoarseStart; hp <= HCoarseEnd; hp += HCoarseStep)
  170. {
  171. int p = ClampH(hp);
  172. _motor.HorizontalMoveTo(p, ScanDelayMs);
  173. var b = Grab();
  174. var c = WellDetector.Detect(b, W, H);
  175. DebugSave?.Invoke(b, $"hcoarse_w{well}_hp{p}");
  176. Log?.Invoke(c.Found
  177. ? $" 粗扫水平{p}: Y偏移={c.OffsetYPct:F1}% 完整={c.Complete}"
  178. : $" 粗扫水平{p}: 未检出圆");
  179. OnStep?.Invoke($"水平粗扫 hp={p}", c, null);
  180. if (c.Found)
  181. {
  182. double score = Math.Abs(c.OffsetYPct) + (c.Complete ? 0 : 100);
  183. if (score < fallbackScore) { fallbackScore = score; fallbackHPos = p; fallbackCircle = c; }
  184. if (c.Complete) { hitHPos = p; hitCircle = c; break; }
  185. }
  186. }
  187. // 命中完整圆 → 局部密扫;否则用检出最优的降级点
  188. int center = hitHPos >= 0 ? hitHPos : fallbackHPos;
  189. if (center < 0)
  190. {
  191. Log?.Invoke($"[well{well}] ✗ 水平全行程未检出任何圆");
  192. return (-1, null);
  193. }
  194. // ② 以命中/降级点为中心局部密扫居中(范围取粗扫步距量级)
  195. int fineRange = HCoarseStep;
  196. var fine = ScanForCenter(well, center, fineRange, FineScanSteps, 800);
  197. if (fine.bestCircle != null) return (fine.bestHPos, fine.bestCircle);
  198. // ③ 局部密扫没检出 → 回退到粗扫命中/降级结果
  199. return (center, hitCircle ?? fallbackCircle);
  200. }
  201. /// <summary>
  202. /// 标定单个 well。eepromHPos=该well的EEPROM水平位置(扫描中心),eepromZ=Z焦准零点。
  203. /// 返回标定结果。
  204. /// </summary>
  205. public WellCalib CalibrateWell(int well, int eepromHPos, int eepromZ)
  206. {
  207. var r = new WellCalib { Well = well };
  208. // 先到 EEPROM 水平位置 + 中低曝光
  209. if (!RetryMove(() => _motor.HorizontalMoveTo(ClampH(eepromHPos), ScanDelayMs), $"well{well}初始水平"))
  210. {
  211. Log?.Invoke($"[well{well}] ✗ 电机移动到初始位置失败(已重试),跳过该well");
  212. return new WellCalib { Well = well, Note = "电机移动失败" };
  213. }
  214. _cam.SetExposure(CenterScanExposure);
  215. // ── ① 粗对焦:先让 well 清晰,否则画面模糊根本找不到圆(采纳用户洞察:
  216. // 对焦与居中耦合,焦不对→画面糊→检不出圆→无法居中。故先粗对焦)──
  217. Log?.Invoke($"[well{well}] ①粗对焦(让well清晰可检)...");
  218. int coarseZ = CoarseFocus(well);
  219. _motor.VerticalMoveTo(ClampZ(coarseZ), ScanDelayMs);
  220. Log?.Invoke($"[well{well}] → 粗对焦Z={coarseZ}");
  221. // ── ② 旋转居中(此时画面清晰,圆可稳定检出;只优化Y偏移)──
  222. // 转动培养皿让well在画面里上下(Y)移动;X的~5%固定偏移是相机/转盘硬件偏移,旋转修不了。
  223. Log?.Invoke($"[well{well}] ②水平全行程定位+居中...");
  224. var located = HCoarseLocate(well);
  225. if (located.bestHPos < 0)
  226. {
  227. Log?.Invoke($"[well{well}] ✗ 水平全行程未找到圆,跳过该well");
  228. return new WellCalib { Well = well, Note = "水平全程未检出圆" };
  229. }
  230. int bestHPos = located.bestHPos;
  231. WellCircle bestCircle = located.bestCircle;
  232. r.HorizontalPulse = bestHPos;
  233. r.CircleFound = bestCircle != null;
  234. r.CenterOffsetPct = bestCircle?.OffsetYPct ?? 0;
  235. if (!RetryMove(() => _motor.HorizontalMoveTo(ClampH(bestHPos), ScanDelayMs), $"well{well}居中水平"))
  236. {
  237. Log?.Invoke($"[well{well}] ✗ 电机移动到居中位置失败(已重试),跳过该well");
  238. return new WellCalib { Well = well, Note = "居中后电机移动失败" };
  239. }
  240. bool centered = bestCircle != null && Math.Abs(r.CenterOffsetPct) < CenterTolPct && bestCircle.Complete;
  241. Log?.Invoke($"[well{well}] → 居中水平={bestHPos} 残留Y偏移={r.CenterOffsetPct:F1}% " +
  242. $"(X={bestCircle?.OffsetXPct ?? 0:F1}%硬件偏移) {(centered ? "✓合格" : "(尽力而为)")}");
  243. // ── ③ 曝光二分 (well内ROI) ──
  244. Log?.Invoke($"[well{well}] ③曝光二分...");
  245. int exp = ExposureMeter.BinarySearch(ExpLo, ExpHi, e =>
  246. {
  247. _cam.SetExposure(e);
  248. // 修复: 等待足够时间让新曝光生效,并丢弃旧帧
  249. Thread.Sleep(Math.Max(200, e / 5)); // 至少2×曝光时间(单位100µs)
  250. Grab(); // 丢弃第一帧(可能是旧曝光)
  251. var b = Grab(); // 使用第二帧
  252. var c = WellDetector.Detect(b, W, H);
  253. var info = ExposureMeter.Measure(b, W, H, c);
  254. OnStep?.Invoke($"曝光二分 e={e}", c, info);
  255. return info;
  256. }, m => Log?.Invoke(m));
  257. r.Exposure = exp;
  258. _cam.SetExposure(exp);
  259. Log?.Invoke($"[well{well}] → 曝光={exp}");
  260. // ── ④ 精对焦 (well圆内ROI, 围绕粗对焦Z小范围密扫) ──
  261. Log?.Invoke($"[well{well}] ④精对焦...");
  262. // 修复P0-5: 丢弃旧帧,确保获取稳定画面
  263. Grab(); // 丢弃第一帧
  264. var b0 = Grab(); // 使用第二帧
  265. var circle = WellDetector.Detect(b0, W, H);
  266. // P1-1: 统一对焦ROI策略 - 使用0.95r保留边缘特征,避免精对焦峰值过弱
  267. // P0-4: 圆未检出时降级到中央40% ROI(与粗对焦一致),绝不用全图(背景/反光严重干扰对焦)
  268. System.Drawing.Rectangle roi = circle.Found
  269. ? new System.Drawing.Rectangle(
  270. Math.Max(0, (int)(circle.Cx - circle.Radius * 0.95)),
  271. Math.Max(0, (int)(circle.Cy - circle.Radius * 0.95)),
  272. (int)(circle.Radius * 1.9), (int)(circle.Radius * 1.9))
  273. : CenterRoi40();
  274. if (!circle.Found)
  275. Log?.Invoke($"[well{well}] ⚠ 精对焦未检出well圆,对焦ROI降级为中央40%(非全图)");
  276. int zstep = FineZStep > 0 ? FineZStep : 500;
  277. int fineLayers = 2 * FineZHalf / zstep + 1; // 半幅6000步距500 → 25层
  278. var curve = new List<(int z, double s)>();
  279. for (int i = 0; i < fineLayers; i++)
  280. {
  281. int z = coarseZ - FineZHalf + zstep * i;
  282. _motor.VerticalMoveTo(ClampZ(z), ScanDelayMs);
  283. // P0-5: 每次移动后丢弃旧帧
  284. Grab(); // 丢弃第一帧
  285. var b = Grab(); // 使用第二帧
  286. double sc = Sharpness.Compute(b, W, H, roi);
  287. curve.Add((z, sc));
  288. OnStep?.Invoke($"精对焦 {i + 1}/{fineLayers} Z={z} 分={sc:F4}", circle, null);
  289. }
  290. // P1-3: 峰值平滑与插值 - 提升对焦精度
  291. // 3点滑动平均平滑
  292. var smoothed = new List<double>();
  293. for (int i = 0; i < curve.Count; i++)
  294. {
  295. double sum = curve[i].s;
  296. int cnt = 1;
  297. if (i > 0) { sum += curve[i - 1].s; cnt++; }
  298. if (i < curve.Count - 1) { sum += curve[i + 1].s; cnt++; }
  299. smoothed.Add(sum / cnt);
  300. }
  301. // 找平滑后的峰值
  302. double max = smoothed.Max();
  303. int pk = smoothed.IndexOf(max);
  304. double mean = smoothed.Average();
  305. // 抛物线插值峰顶(如果峰值不在边界)
  306. int focusZ = curve[pk].z;
  307. if (pk > 0 && pk < curve.Count - 1)
  308. {
  309. double y0 = smoothed[pk - 1], y1 = smoothed[pk], y2 = smoothed[pk + 1];
  310. double denominator = y0 - 2 * y1 + y2;
  311. if (Math.Abs(denominator) > 1e-9)
  312. {
  313. double delta = 0.5 * (y0 - y2) / denominator; // 抛物线顶点偏移
  314. focusZ = curve[pk].z + (int)(delta * zstep);
  315. }
  316. }
  317. r.FocusZ = focusZ;
  318. r.PeakSharp = max;
  319. r.PeakRatio = mean > 1e-9 ? max / mean : 1;
  320. // 检查对焦质量:峰过弱可能是空well或对焦失败
  321. if (r.PeakRatio < 1.2)
  322. {
  323. Log?.Invoke($"[well{well}] ⚠ 对焦峰过弱(ratio={r.PeakRatio:F2}),可能空well或对焦失败");
  324. r.Note += "对焦峰弱;";
  325. }
  326. _motor.VerticalMoveTo(ClampZ(r.FocusZ), ScanDelayMs);
  327. Log?.Invoke($"[well{well}] → 最清晰Z={r.FocusZ} 峰值={max:F4} max/mean={r.PeakRatio:F2} " +
  328. $"{(r.PeakRatio < 1.2 ? "(弱峰/可能空well)" : "✓有焦点")}");
  329. r.Note = $"{(r.CircleFound ? "" : "未检到well圆;")}{(r.PeakRatio < 1.2 ? "对焦峰弱;" : "")}";
  330. return r;
  331. }
  332. /// <summary>
  333. /// 粗对焦:固定中心 ZCoarseCenter±ZCoarseHalf 按 ZCoarseStep 扫描,中央40% ROI 找焦点。
  334. /// 实测所有 well 焦面集中在 60000~120000,故用固定大窗口,不依赖 EEPROM 零点。
  335. /// </summary>
  336. int CoarseFocus(int well)
  337. {
  338. int lo = ZCoarseCenter - ZCoarseHalf;
  339. int hi = ZCoarseCenter + ZCoarseHalf;
  340. int bestZ = ZCoarseCenter; double bestS = -1;
  341. // P0-6: 粗对焦使用中央40%区域ROI,避免背景干扰
  342. var centerROI = CenterRoi40();
  343. int layers = 0;
  344. for (int z = lo; z <= hi; z += ZCoarseStep)
  345. {
  346. layers++;
  347. _motor.VerticalMoveTo(ClampZ(z), ScanDelayMs);
  348. // P0-5: 丢弃旧帧
  349. Grab();
  350. var b = Grab();
  351. double sc = Sharpness.Compute(b, W, H, centerROI); // 中央ROI(避免背景带偏)
  352. if (sc > bestS) { bestS = sc; bestZ = z; }
  353. OnStep?.Invoke($"粗对焦 Z={z} (区间{lo}~{hi})", null, null);
  354. }
  355. Log?.Invoke($"[well{well}] 粗对焦扫{layers}层 区间[{lo},{hi}] 步距{ZCoarseStep}");
  356. return bestZ;
  357. }
  358. }
  359. }