using System; using System.Collections.Generic; using System.Linq; using System.Threading; using AutoFocusTool.Serial; using AutoFocusTool.Imaging; using SerialCamera = AutoFocusTool.Camera.Camera; namespace AutoFocusTool.Calib { /// /// 自动标定引擎(可复用,GUI/命令行共用)。封装单well标定四步: /// ①粗对焦(中央40% ROI) → ②水平扫描找最居中位置 → ③曝光二分(well内ROI) → ④精对焦(0.95r ROI + 平滑插值) /// 通过回调上报进度(文字)与实时帧(byte[]),便于界面实时显示。 /// public class CalibrationEngine { readonly int W, H; // 取自相机实际分辨率,绝不能硬编码 readonly HouseMotor _motor; readonly SerialCamera _cam; public Action Log; public Action OnFrame; // 实时帧回调(原始BGR) public Action OnStep; // 步骤+当前圆/曝光 /// 居中容差(Y偏移百分比),落入即合格,避免来回震荡。 public double CenterTolPct = 12; /// 粗扫范围(脉冲,零点±range)与步数。 public int HScanRange = 4000; public int HScanSteps = 7; /// 细扫步数(在粗扫最佳点附近精调Y)。 public int FineScanSteps = 7; /// Z扫描半幅与层数。 public int ZHalf = 1500, ZLayers = 9; public int ExpLo = 10, ExpHi = 800; /// 扫描时电机稳定延时(ms)。小步移动无需1500ms,用短延时大幅提速。 public int ScanDelayMs = 350; /// 粗对焦层数(先让well清晰,再居中)。 public int CoarseFocusLayers = 7; /// 居中扫描用的固定曝光(well是暗背景上中灰盘,HScan实测~60可清晰检圆)。 public Action DebugSave; // 诊断存图(帧,文件名前缀),可选 public int CenterScanExposure = 60; public CalibrationEngine(HouseMotor motor, SerialCamera cam) { _motor = motor; _cam = cam; W = cam.Width; H = cam.Height; } /// 中央40% ROI(精对焦/粗对焦圆未检出时的降级,避免背景边缘带偏)。 System.Drawing.Rectangle CenterRoi40() { int roiW = (int)(W * 0.4), roiH = (int)(H * 0.4); return new System.Drawing.Rectangle((W - roiW) / 2, (H - roiH) / 2, roiW, roiH); } /// /// P0-6: 关键定位移动带重试。下位机偶发不回复(串口噪声/忙)时直接放弃整个well过于脆弱, /// 重试最多 maxRetry 次,每次间隔后再试。 /// bool RetryMove(Func move, string what, int maxRetry = 3) { for (int i = 0; i < maxRetry; i++) { if (move()) return true; Log?.Invoke($" {what} 移动失败,重试 {i + 1}/{maxRetry}"); Thread.Sleep(400); } return false; } byte[] Grab() { int retry = 0; while (retry < 3) { try { _cam.GrabRgb(); var b = _cam.GetSourceBuffer(); if (b != null && b.Length > 0) { OnFrame?.Invoke(b); return b; } } catch (Exception ex) { Log?.Invoke($"抓帧失败(重试{retry + 1}/3): {ex.Message}"); } retry++; Thread.Sleep(50); } throw new Exception("抓帧失败,已重试3次"); } /// /// 绕 center±range 扫 steps 个水平位置,找 well 检出且 |Y偏移| 最小的位置。 /// 转动培养皿让 well 在画面上下(Y)移动,故按 Y 偏移评分;优先完整。 /// (int bestHPos, WellCircle bestCircle) ScanForCenter(int well, int center, int range, int steps, int delayMs = -1) { int bestHPos = center; double bestScore = double.MaxValue; WellCircle best = null; int step = steps > 1 ? 2 * range / (steps - 1) : 1; int actualDelay = delayMs > 0 ? delayMs : ScanDelayMs; // 使用传入延时或默认值 // 固定中低曝光设一次即可:well 是暗背景上的中灰盘,不能按全图均值自动曝光(会过曝well盘) _cam.SetExposure(CenterScanExposure); for (int i = 0; i < steps; i++) { int hp = center - range + step * i; if (hp < 0) continue; _motor.HorizontalMoveTo(hp, actualDelay); var b = Grab(); var c = WellDetector.Detect(b, W, H); DebugSave?.Invoke(b, $"center_w{well}_hp{hp}"); Log?.Invoke(c.Found ? $" 水平{hp}: Y偏移={c.OffsetYPct:F1}% X={c.OffsetXPct:F1}% 完整={c.Complete}" : $" 水平{hp}: 未检出圆"); OnStep?.Invoke($"居中扫描 hp={hp}", c, null); if (c.Found) { // 按 |Y偏移| 评分,不完整重罚 double score = Math.Abs(c.OffsetYPct) + (c.Complete ? 0 : 100); if (score < bestScore) { bestScore = score; bestHPos = hp; best = c; } } } return (bestHPos, best); } /// /// 标定单个 well。eepromHPos=该well的EEPROM水平位置(扫描中心),eepromZ=Z焦准零点。 /// 返回标定结果。 /// public WellCalib CalibrateWell(int well, int eepromHPos, int eepromZ) { var r = new WellCalib { Well = well }; // 先到 EEPROM 水平位置 + 中低曝光 if (!RetryMove(() => _motor.HorizontalMoveTo(eepromHPos, ScanDelayMs), $"well{well}初始水平")) { Log?.Invoke($"[well{well}] ✗ 电机移动到初始位置失败(已重试),跳过该well"); return new WellCalib { Well = well, Note = "电机移动失败" }; } _cam.SetExposure(CenterScanExposure); // ── ① 粗对焦:先让 well 清晰,否则画面模糊根本找不到圆(采纳用户洞察: // 对焦与居中耦合,焦不对→画面糊→检不出圆→无法居中。故先粗对焦)── Log?.Invoke($"[well{well}] ①粗对焦(让well清晰可检)..."); int coarseZ = CoarseFocus(well, eepromZ, ZHalf, CoarseFocusLayers); _motor.VerticalMoveTo(coarseZ, ScanDelayMs); Log?.Invoke($"[well{well}] → 粗对焦Z={coarseZ}"); // ── ② 旋转居中(此时画面清晰,圆可稳定检出;只优化Y偏移)── // 转动培养皿让well在画面里上下(Y)移动;X的~5%固定偏移是相机/转盘硬件偏移,旋转修不了。 Log?.Invoke($"[well{well}] ②旋转居中(优化Y偏移)..."); var coarse = ScanForCenter(well, eepromHPos, HScanRange, HScanSteps); int fineRange = Math.Max(300, 2 * HScanRange / Math.Max(1, HScanSteps - 1)); var fine = ScanForCenter(well, coarse.bestHPos, fineRange, FineScanSteps, 800); // 细扫用800ms长延时确保检测准确 int bestHPos = fine.bestCircle != null ? fine.bestHPos : coarse.bestHPos; WellCircle bestCircle = fine.bestCircle ?? coarse.bestCircle; r.HorizontalPulse = bestHPos; r.CircleFound = bestCircle != null; r.CenterOffsetPct = bestCircle?.OffsetYPct ?? 0; if (!RetryMove(() => _motor.HorizontalMoveTo(bestHPos, ScanDelayMs), $"well{well}居中水平")) { Log?.Invoke($"[well{well}] ✗ 电机移动到居中位置失败(已重试),跳过该well"); return new WellCalib { Well = well, Note = "居中后电机移动失败" }; } bool centered = bestCircle != null && Math.Abs(r.CenterOffsetPct) < CenterTolPct && bestCircle.Complete; Log?.Invoke($"[well{well}] → 居中水平={bestHPos} 残留Y偏移={r.CenterOffsetPct:F1}% " + $"(X={bestCircle?.OffsetXPct ?? 0:F1}%硬件偏移) {(centered ? "✓合格" : "(尽力而为)")}"); // ── ③ 曝光二分 (well内ROI) ── Log?.Invoke($"[well{well}] ③曝光二分..."); int exp = ExposureMeter.BinarySearch(ExpLo, ExpHi, e => { _cam.SetExposure(e); // 修复: 等待足够时间让新曝光生效,并丢弃旧帧 Thread.Sleep(Math.Max(200, e / 5)); // 至少2×曝光时间(单位100µs) Grab(); // 丢弃第一帧(可能是旧曝光) var b = Grab(); // 使用第二帧 var c = WellDetector.Detect(b, W, H); var info = ExposureMeter.Measure(b, W, H, c); OnStep?.Invoke($"曝光二分 e={e}", c, info); return info; }, m => Log?.Invoke(m)); r.Exposure = exp; _cam.SetExposure(exp); Log?.Invoke($"[well{well}] → 曝光={exp}"); // ── ④ 精对焦 (well圆内ROI, 围绕粗对焦Z小范围密扫) ── Log?.Invoke($"[well{well}] ④精对焦..."); // 修复P0-5: 丢弃旧帧,确保获取稳定画面 Grab(); // 丢弃第一帧 var b0 = Grab(); // 使用第二帧 var circle = WellDetector.Detect(b0, W, H); // P1-1: 统一对焦ROI策略 - 使用0.95r保留边缘特征,避免精对焦峰值过弱 // P0-4: 圆未检出时降级到中央40% ROI(与粗对焦一致),绝不用全图(背景/反光严重干扰对焦) System.Drawing.Rectangle roi = circle.Found ? new System.Drawing.Rectangle( Math.Max(0, (int)(circle.Cx - circle.Radius * 0.95)), Math.Max(0, (int)(circle.Cy - circle.Radius * 0.95)), (int)(circle.Radius * 1.9), (int)(circle.Radius * 1.9)) : CenterRoi40(); if (!circle.Found) Log?.Invoke($"[well{well}] ⚠ 精对焦未检出well圆,对焦ROI降级为中央40%(非全图)"); int fineZHalf = Math.Max(200, ZHalf / 3); // 精对焦围绕粗焦点小范围 int zstep = ZLayers > 1 ? 2 * fineZHalf / (ZLayers - 1) : 1; var curve = new List<(int z, double s)>(); for (int i = 0; i < ZLayers; i++) { int z = Math.Max(0, coarseZ - fineZHalf) + zstep * i; _motor.VerticalMoveTo(z, ScanDelayMs); // P0-5: 每次移动后丢弃旧帧 Grab(); // 丢弃第一帧 var b = Grab(); // 使用第二帧 double sc = Sharpness.Compute(b, W, H, roi); curve.Add((z, sc)); OnStep?.Invoke($"精对焦 {i + 1}/{ZLayers} Z={z} 分={sc:F4}", circle, null); } // P1-3: 峰值平滑与插值 - 提升对焦精度 // 3点滑动平均平滑 var smoothed = new List(); for (int i = 0; i < curve.Count; i++) { double sum = curve[i].s; int cnt = 1; if (i > 0) { sum += curve[i - 1].s; cnt++; } if (i < curve.Count - 1) { sum += curve[i + 1].s; cnt++; } smoothed.Add(sum / cnt); } // 找平滑后的峰值 double max = smoothed.Max(); int pk = smoothed.IndexOf(max); double mean = smoothed.Average(); // 抛物线插值峰顶(如果峰值不在边界) int focusZ = curve[pk].z; if (pk > 0 && pk < curve.Count - 1) { double y0 = smoothed[pk - 1], y1 = smoothed[pk], y2 = smoothed[pk + 1]; double denominator = y0 - 2 * y1 + y2; if (Math.Abs(denominator) > 1e-9) { double delta = 0.5 * (y0 - y2) / denominator; // 抛物线顶点偏移 focusZ = curve[pk].z + (int)(delta * zstep); } } r.FocusZ = focusZ; r.PeakSharp = max; r.PeakRatio = mean > 1e-9 ? max / mean : 1; // 检查对焦质量:峰过弱可能是空well或对焦失败 if (r.PeakRatio < 1.2) { Log?.Invoke($"[well{well}] ⚠ 对焦峰过弱(ratio={r.PeakRatio:F2}),可能空well或对焦失败"); r.Note += "对焦峰弱;"; } _motor.VerticalMoveTo(r.FocusZ, ScanDelayMs); Log?.Invoke($"[well{well}] → 最清晰Z={r.FocusZ} 峰值={max:F4} max/mean={r.PeakRatio:F2} " + $"{(r.PeakRatio < 1.2 ? "(弱峰/可能空well)" : "✓有焦点")}"); r.Note = $"{(r.CircleFound ? "" : "未检到well圆;")}{(r.PeakRatio < 1.2 ? "对焦峰弱;" : "")}"; return r; } /// /// 粗对焦:围绕 centerZ±half 扫 layers 层,使用中央ROI清晰度找焦点。 /// 目的是先把画面调清楚,让后续居中能稳定检出圆(对焦与居中耦合)。 /// P0-6修复:使用中央40% ROI避免被背景边缘带偏。 /// int CoarseFocus(int well, int centerZ, int half, int layers) { int zstep = layers > 1 ? 2 * half / (layers - 1) : 1; int bestZ = centerZ; double bestS = -1; // P0-6: 粗对焦使用中央40%区域ROI,避免背景干扰 var centerROI = CenterRoi40(); for (int i = 0; i < layers; i++) { int z = Math.Max(0, centerZ - half) + zstep * i; _motor.VerticalMoveTo(z, ScanDelayMs); // P0-5: 丢弃旧帧 Grab(); var b = Grab(); double sc = Sharpness.Compute(b, W, H, centerROI); // 中央ROI(避免背景带偏) if (sc > bestS) { bestS = sc; bestZ = z; } OnStep?.Invoke($"粗对焦 {i + 1}/{layers} Z={z}", null, null); } return bestZ; } } }