Prechádzať zdrojové kódy

fix(d2-02-p5): 根治多舱并发拍照崩溃(相机分辨率缓存污染致Save2越界)+对焦端到端闭环

真机联调发现多舱(2/4/6/8)同时对焦+拍照时 control 必崩(coreclr AV c0000005,
偏移固定0x1d4089)。createdump 抓1.1G dump + dotnet-dump 实证根因:

- HAL 按 index 缓存唯一相机、"首建为准";开机 SerialBin.GetCameraSn 走默认重载
  GetCamera(i)=1600x1200 先把相机缓存成默认分辨率;采集端 GetCamera(ccdId,2592,1944)
  被忽略、拿到那台1600x1200 → SourceBuffer 出 5.76MB 缓冲,而 Save2 按 2592x1944
  读 15.1MB → 越界读~9.4MB 砸坏托管堆 → coreclr 固定偏移崩。舱越多越易撞污染相机。
- 老代码不犯:SN枚举用临时 new Camera、采集单独 new Camera(正确分辨率),实例独立。

修复(HardwareAccessLayer.cs):带分辨率 GetCamera 发现缓存相机分辨率不符即 Dispose
重建;默认重载改为已建则原样返回不降级。验证:4舱同时对焦+拍照34+分钟/92+张图/
零崩溃,图均2592x1944全分辨率。

同批闭环补丁(HouseBin/AppData/HouseBinController + 新增 CalAutoFocusPositionRequestDTO):
- FocusZ 经 /api/tl/control/autofocus/cal/position 上报 calAutofocusPosition(补闭环缺口)
- StartAutoFocus 对焦临开临关 LED;无气源补气提前退出;FirstClearest 循环退出bug修正
- application-local.properties Feign 名对齐网关(aivfo-business-manage-pc-dev)

排查期临加的 _captureCriticalGate 多余(根因非并发),待回退;多舱对焦节拍优化待办。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 23 hodín pred
rodič
commit
5a68cb859e

+ 1 - 1
aivof-tl-control/aivfo-tl-control-lanucher/src/main/resources/application-local.properties

@@ -48,7 +48,7 @@ aivfo.alarm.logEnable=true
 aivfo.alarm.diskPath[0]=/mnt/data01
 aivfo.alarm.serverMinDiskSpace=5%
 aivfo.alarm.saveAlarmData=true
-feign.server.name.aivfo-business-manage-pc=aivfo-business-manage-pc
+feign.server.name.aivfo-business-manage-pc=aivfo-business-manage-pc-dev
 feign.server.name.aivfo-date-transmission=aivfo-date-transmission-dev
 #地图资源加载
 aivfo.region.enable=false

+ 22 - 4
ivf_tl_operate_2.0/control/IvfTl.Hardware/Impl/HardwareAccessLayer.cs

@@ -57,17 +57,35 @@ namespace IvfTl.Hardware.Impl
         }
 
         // ── 唯一持有:相机 ──
-        public ICamera GetCamera(int cameraIndex) => GetCamera(cameraIndex, 1600, 1200, 400);
+        // 默认分辨率重载(SN 枚举等过渡用途):已建则原样返回,绝不把已按正确分辨率建好的实例降级重建。
+        public ICamera GetCamera(int cameraIndex)
+        {
+            lock (_lock)
+            {
+                if (_cameras.TryGetValue(cameraIndex, out var cam)) return cam;
+                cam = new CameraImpl(cameraIndex, 1600, 1200, 400, _cameraGate);
+                _cameras[cameraIndex] = cam;
+                return cam;
+            }
+        }
 
         public ICamera GetCamera(int cameraIndex, int width, int height, int exposure)
         {
             lock (_lock)
             {
-                if (!_cameras.TryGetValue(cameraIndex, out var cam))
+                if (_cameras.TryGetValue(cameraIndex, out var cam))
                 {
-                    cam = new CameraImpl(cameraIndex, width, height, exposure, _cameraGate);
-                    _cameras[cameraIndex] = cam;
+                    // D2-02 多舱拍照崩溃根因修复:缓存相机分辨率与本次请求不一致时,丢弃旧实例按正确分辨率重建。
+                    // 背景:开机 SN 枚举经默认重载先把相机缓存成 1600×1200,采集端实际需 ccdWidth×ccdHeight(如 2592×1944);
+                    // HAL 原"首建为准"会返回那台 1600×1200 → SourceBuffer 仅出 5.76MB 缓冲,而 Save2 按 2592×1944 读 15MB
+                    // → 越界读砸坏托管堆 → coreclr 固定偏移 AV(createdump 实证:byte[]=5,760,000 而 width*height*3=15,116,544)。
+                    if (cam.Width == width && cam.Height == height) return cam;
+                    Log?.Invoke($"[HAL] 相机 {cameraIndex} 缓存分辨率 {cam.Width}x{cam.Height} 与请求 {width}x{height} 不符,按正确分辨率重建。");
+                    try { cam.Dispose(); } catch { }
+                    _cameras.Remove(cameraIndex);
                 }
+                cam = new CameraImpl(cameraIndex, width, height, exposure, _cameraGate);
+                _cameras[cameraIndex] = cam;
                 return cam;
             }
         }

+ 119 - 23
ivf_tl_operate_2.0/control/ivf_tl_Com/HouseBin.cs

@@ -55,6 +55,15 @@ namespace ivf_tl_Com
         /// </summary>
         public event Func<int, Dictionary<int, DateTime?>, List<HouseWellPhoto>> GetAutoFocusDBEvent;
 
+        /// <summary>
+        /// D2-02 闭环:本地四步对焦算出 FocusZ 后上报服务器(calAutofocusPosition)。
+        /// 服务器据此排"清晰层 FocusZ + 上下对称拍照层"写 t_house_photograph_setting;
+        /// control 随后用同一 autofocusTime 经 /ccd/position(GetCCDServiceEvent) 取回这些层拍照。
+        /// 这是采集对焦闭环里"本地FocusZ→服务器排层"的关键一环(老系统是拍对焦图→服务器评分选层,新系统无评分、直接上报FocusZ)。
+        /// 参数:houseSn, wellSn, focusZ(=clearPosition), autofocusTime("yyyy-MM-dd HH:mm:ss"), 成功层数(无评分,传足够大值越阈值)。返回是否上报成功。
+        /// </summary>
+        public event Func<int, int, int, string, int, bool> UploadAutoFocusEvent;
+
         /// <summary>
         /// M2-04 标定结果存储入口(写本地 calibration.json 真相源 + 镜像 house_autofocus_calibration)。
         /// 由 control 端(AppData)在 InitHouseBinEvent 时注入并配置 JsonPath/DbMirror;
@@ -554,8 +563,8 @@ namespace ivf_tl_Com
                             TLLogEvent?.Invoke($"[{House.houseSn}][{this.PortName}]相机连续模式设置结果{cameraSetMode},结束初始化[注:0表示成功]", LogEnum.RunError);
                             return;
                         }
-                        Camera.GetRgbData();
-                        Camera.SavePreMem();
+                        CamGate(() => Camera.GetRgbData());
+                        CamGate(() => Camera.SavePreMem());
                     }
                     else
                     {
@@ -1345,6 +1354,14 @@ namespace ivf_tl_Com
                     Thread.Sleep(TLSetting.aerationDelay * 1000);
                     HouseState(commandSource, custom, 0);
                     HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{this.PortName}]补气完成[{oldPressure}->{Pressure}]", LogEnum.HouseInfo);
+                    // D2-02 真机:无气源时压力补不上(0->0)。不再跑满 houseAerationNum 次空补气拖死采集——
+                    //   补一次后压力没上升即判定无气源,停止本次补气;有气源(压力上升)则继续直到达标。
+                    //   修复"没气严重拖慢/卡住拍照"(对焦后冗余补气、换气、拍照逐孔补气在无气时各跑满35s)。
+                    if (Pressure <= oldPressure)
+                    {
+                        HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{this.PortName}][补气后压力未上升({oldPressure}->{Pressure}),疑无气源,停止本次补气(不拖慢采集)]", LogEnum.HouseInfo);
+                        break;
+                    }
                 }
                 this.ValveState = State.正常;
                 if (Pressure < TLSetting.pressureAlarmMin) this.ValveState = State.待补气;
@@ -1577,9 +1594,19 @@ namespace ivf_tl_Com
                         return false;
                     }
 
+                    // D2-02 闭环:本地对焦需补光 —— 临开 LED(对齐老 AllEmbryoAutofocus 的开灯→对焦→关灯);
+                    //   实测黑灯下对焦峰比仅 1.01(检不到圆),开灯后 1.76(找到清晰面)。本轮所有 well 共用一个对焦时间戳=批次键。
+                    DateTime focusTime = DateTime.Now;
+                    try { lease.Serial.OpenLedWait(); } catch (Exception exLed) { HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{this.PortName}][本地对焦开LED失败:{exLed.Message}]", LogEnum.HouseInfo); }
+                    HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{this.PortName}][本地对焦诊断:wellList数={wellList.Count}, IsStopClearest={IsStopClearest}, FirstClearest={FirstClearest}, gate启用={localAfEnabled}]", LogEnum.HouseInfo);
+                    try
+                    {
                     foreach (var well in wellList.Keys)
                     {
-                        if (IsStopClearest || FirstClearest) break;
+                        // 只按显式停止信号 IsStopClearest 中断。
+                        // ★不能看 FirstClearest:StartAutoFocus 由 MainThread 在 FirstClearest=true 时调用、返回后才置 false,
+                        //   若这里 break on FirstClearest 会第一轮就退出、一个 well 都不对焦(M2-03 遗留bug,gate=1 首次真机暴露)。
+                        if (IsStopClearest) break;
                         try
                         {
                             // 自动对焦重构 Task2.2:读 per-well 运动范围(半幅)+中心(DB),逐 well 注入引擎。
@@ -1629,6 +1656,34 @@ namespace ivf_tl_Com
                             {
                                 ExceptionLogEvent?.Invoke(exStore, $"[{House.houseSn}][{this.PortName}][{well}号well标定结果存储失败]", null, LogEnum.RunException);
                             }
+
+                            // === D2-02 闭环关键缺口:把本地算出的 FocusZ 上报服务器(calAutofocusPosition),服务器据此排各拍照层 ===
+                            // 合格(检到圆 + 峰比>阈值)才上报;不合格不上报 = 保留上次拍照位置(不污染)。
+                            // 上报成功才把 autofocusTime 写入 LastAutoFocusTimeDic —— StartCCD 的 IsCCD() 重建 wellList 时
+                            // 会带上同一时间戳,使 /ccd/position 取回的正是刚上报排出的那组层(时间戳天然对齐)。
+                            double peakThr = (double)(FocusPeakRatioThreshold ?? 1.2m);
+                            // 甲方案(用户定):上报"合格"只看对焦峰比>阈值(找到清晰焦面即可驱动拍照层);
+                            //   圆检测(水平居中质量)只记日志、不拦上报 —— 避免某些皿型/相机倍率检不出完整 well 圆而卡死整条对焦闭环。
+                            bool qualified = wc.PeakRatio > peakThr;
+                            if (qualified)
+                            {
+                                string afTime = focusTime.ToString("yyyy-MM-dd HH:mm:ss");
+                                // 新系统无云端评分,本地对焦合格即希望服务器接受该 FocusZ,成功层数传足够大值以越过服务器阈值。
+                                bool uploaded = UploadAutoFocusEvent?.Invoke(House.houseSn, well, wc.FocusZ, afTime, 999) ?? false;
+                                if (uploaded)
+                                {
+                                    LastAutoFocusTimeDic[well] = focusTime;
+                                    HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{this.PortName}][{well}号well本地对焦合格(FocusZ={wc.FocusZ},峰比={wc.PeakRatio:F2})已上报,拍照层将绕此焦面]", LogEnum.HouseInfo);
+                                }
+                                else
+                                {
+                                    HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{this.PortName}][{well}号well本地对焦合格但上报失败,本轮保留上次拍照位置]", LogEnum.HouseInfo);
+                                }
+                            }
+                            else
+                            {
+                                HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{this.PortName}][{well}号well本地对焦不合格(检到圆={wc.CircleFound},峰比={wc.PeakRatio:F2}≤阈值{peakThr:F2}),不上报,保留上次拍照位置]", LogEnum.HouseInfo);
+                            }
                         }
                         catch (Exception exWell)
                         {
@@ -1636,6 +1691,8 @@ namespace ivf_tl_Com
                             ExceptionLogEvent?.Invoke(exWell, $"[{House.houseSn}][{this.PortName}][{well}号well本地自动对焦失败]", null, LogEnum.RunException);
                         }
                     }
+                    }
+                    finally { try { lease.Serial.CloseLedWait(); } catch { } }   // D2-02:对焦结束关灯(胚胎不长期受光)
                 } // lease.Dispose() → 归还借用,HAL 恢复采集
             }
             catch (Exception ex)
@@ -1832,7 +1889,7 @@ namespace ivf_tl_Com
                     HouseState(commandSource, custom, 0);
                     if (isWuTu)
                     {
-                        var startNoView = Camera.Usb2StartCapture();
+                        var startNoView = CamGate(() => Camera.Usb2StartCapture());
                         HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][无图预览开启结果:{startNoView}]", LogEnum.HouseInfo);
                     }
                     //打开CCD补光LED
@@ -1876,12 +1933,15 @@ namespace ivf_tl_Com
                         // 整段移除——拍出来无人评、无人看,纯空转。FocusZ 产生/消费(StartAutoFocus/CalibrationEngine/_autoFocusPhoto)不受影响。
                         // 保留:well 遍历骨架、电机就位、LastAutoFocusTimeDic 置位(改无条件,喂服务器查胚胎照位置的时间戳)、isSuccess 返回真(门控 MainThread GetClearest→StartCCD)。
                         // ────────────────────────────────────────────────────────────────
-                        LastAutoFocusTimeDic[itemEmbryo.wellSn] = SavePictrueTime;
+                        // D2-02 闭环:时间戳改由 StartAutoFocus 在上报 FocusZ 成功时统一写入 LastAutoFocusTimeDic
+                        //   (与 calAutofocusPosition 上报用同一 autofocusTime,保证 StartCCD 取层时间戳对齐)。
+                        //   此处不再覆盖,否则会用一个晚于上报的时间覆盖掉、导致 /ccd/position 取不到刚排的层。
+                        // LastAutoFocusTimeDic[itemEmbryo.wellSn] = SavePictrueTime;
                     }
                     ComBin.CloseLEDWait(custom);
                     if (isWuTu)
                     {
-                        var stopNoView = Camera.Usb2StopCapture();
+                        var stopNoView = CamGate(() => Camera.Usb2StopCapture());
                         HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][无图预览关闭结果:{stopNoView}]", LogEnum.HouseInfo);
                     }
                     ComBin.HorizontalMotorResetWait(custom, TLSetting.motorDelay);
@@ -1933,7 +1993,7 @@ namespace ivf_tl_Com
                 HouseState(commandSource, custom, 0);
                 if (isWuTu)
                 {
-                    var startNoView = Camera.Usb2StartCapture();
+                    var startNoView = CamGate(() => Camera.Usb2StartCapture());
                     HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][无图预览开启结果:{startNoView}]", LogEnum.HouseInfo);
                 }
                 ComBin.OpenLEDWait(custom);
@@ -1993,7 +2053,8 @@ namespace ivf_tl_Com
                         // 保留:DeleteAutoFocusWell(对焦队列推进,删了 do-while 死循环)、LastAutoFocusTimeDic 置位(无条件)、isSuccess 返回真。
                         // ────────────────────────────────────────────────────────────────
                         DeleteAutoFocusWell(embryoItem.wellSn);
-                        LastAutoFocusTimeDic[embryoItem.wellSn] = SavePictrueTime;
+                        // D2-02 闭环:时间戳改由 StartAutoFocus 上报时统一写入(同 autofocusTime),此处不再覆盖。
+                        // LastAutoFocusTimeDic[embryoItem.wellSn] = SavePictrueTime;
                     }
                 } while (AutoFocusWellAny());
 
@@ -2002,7 +2063,7 @@ namespace ivf_tl_Com
 
                 if (isWuTu)
                 {
-                    var stopNoView = Camera.Usb2StopCapture();
+                    var stopNoView = CamGate(() => Camera.Usb2StopCapture());
                     HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][无图预览关闭结果:{stopNoView}]", LogEnum.HouseInfo);
                 }
 
@@ -2117,7 +2178,7 @@ namespace ivf_tl_Com
                 int allPicNum = House.photographPictureNumber;
                 if (isWuTu)
                 {
-                    var startNoView = Camera.Usb2StartCapture();
+                    var startNoView = CamGate(() => Camera.Usb2StartCapture());
                     HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][无图预览开启结果:{startNoView}]", LogEnum.HouseInfo);
                 }
                 ComBin.OpenLEDWait(custom);
@@ -2179,7 +2240,7 @@ namespace ivf_tl_Com
                 ComBin.CloseLEDWait(custom);
                 if (isWuTu)
                 {
-                    var stopNoView = Camera.Usb2StopCapture();
+                    var stopNoView = CamGate(() => Camera.Usb2StopCapture());
                     HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][无图预览关闭结果:{stopNoView}]", LogEnum.HouseInfo);
                 }
                 ComBin.HorizontalMotorResetWait(custom, TLSetting.motorDelay);
@@ -2230,11 +2291,11 @@ namespace ivf_tl_Com
                     {
                         if (isWuTu)
                         {
-                            GetRgbData = Camera.GetRawData();//获取字节流
+                            GetRgbData = CamGate(() => Camera.GetRawData());//获取字节流(走全局相机锁)
                             HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[第{i}次无图预览抓拍结果:{GetRgbData}][注:0表示成功]", LogEnum.HouseRunRecord);
                             if (GetRgbData == 0)
                             {
-                                GetRgbData = Camera.RawToRgb();
+                                GetRgbData = CamGate(() => { int r = Camera.RawToRgb(); if (r == 0) _capturedFrame = Camera.SourceBuffer; return r; });//RawToRgb+取缓冲原子(同一锁内)
                                 HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[rawToRgb结果:{GetRgbData}][注:0表示成功]", LogEnum.HouseRunRecord);
                             }
                         }
@@ -2246,7 +2307,7 @@ namespace ivf_tl_Com
                             }
                             else
                             {
-                                GetRgbData = Camera.GetRgbData();//获取字节流
+                                GetRgbData = CamGate(() => { int r = Camera.GetRgbData(); if (r == 0) _capturedFrame = Camera.SourceBuffer; return r; });//抓图+取缓冲原子(同一相机锁内,防别舱改pDest致AV崩溃)
                             }
                             HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[第{i}次抓拍结果:{GetRgbData}][注:0表示成功]", LogEnum.HouseRunRecord);
                         }
@@ -2309,6 +2370,26 @@ namespace ivf_tl_Com
         /// <param name="content"></param>
         /// <param name="wellSn"></param>
         /// <returns></returns>
+        // ── D2-02 真机并发修复:采集(拍照)路径的相机原生调用补走【全局相机锁】 ──
+        // mvcapi.dll 非线程安全、全进程多相机共用一把锁(ICameraGate)。合并时对焦/调试已迁到守锁的 CameraImpl,
+        // 采集仍用裸 Camera(RawCamera)直连、绕过了锁 → 多舱并发抓图/取缓冲与别舱的有锁原生调用撞车 → mvcapi 死锁
+        // (实测:多舱并发时拍照取 Camera.SourceBuffer 卡死)。下面两个 helper 把采集相机调用也串到同一把锁上。
+        private T CamGate<T>(Func<T> nativeCall) => HardwareAccessLayer.Instance.CameraGate.Invoke(nativeCall);
+        private void CamGate(Action nativeCall) => HardwareAccessLayer.Instance.CameraGate.Invoke(nativeCall);
+
+        // D2-02 真机崩溃修复:抓图那一刻在同一把相机锁内把帧拷成托管缓冲(原子)。
+        // 拍照存图用这个快照,而非再次实时读 Camera.SourceBuffer(那会与别舱抓图争用共享 native pDest → AV 崩溃)。
+        private byte[] _capturedFrame = null;
+
+        // D2-02 真机崩溃修复(根因·全局):多舱并发拍照时,整条"拍照关键段"(抓图→存DB→存图Save2→上传)
+        // 串起 mvcapi.dll / Project2.dll / SQLite / Kafka / protobuf 多个无法核实线程安全的原生/三方组件,
+        // 3 舱并发拍照即在 coreclr 报堆破坏 AV(故障模块 coreclr.dll,固定偏移,c0000005)。逐项已排除
+        // 共享缓冲/static/尺寸不符/相机共享/上锁缺口 → 结论:拍照流水线整体非并发安全。
+        // 与既有"全局相机锁(ICameraGate)"同理:进程级一把静态锁,把整段拍照关键段跨舱串行化。
+        // 对焦/电机移动不受此锁约束,各舱仍并发推进;只有图像抓取-落盘-上传这段互斥(每层秒级,节拍内可容纳)。
+        private static readonly object _captureCriticalGate = new object();
+
+
         public int GetRgbDataFunNew(string content, int wellSn)
         {
             CustomProtocol custom = new CustomProtocol();
@@ -2336,11 +2417,11 @@ namespace ivf_tl_Com
                     {
                         if (isWuTu)
                         {
-                            GetRgbData = Camera.GetRawData();//获取字节流
+                            GetRgbData = CamGate(() => Camera.GetRawData());//获取字节流(走全局相机锁)
                             HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[第{i}次无图预览抓拍结果:{GetRgbData}][注:0表示成功]", LogEnum.HouseRunRecord);
                             if (GetRgbData == 0)
                             {
-                                GetRgbData = Camera.RawToRgb();
+                                GetRgbData = CamGate(() => { int r = Camera.RawToRgb(); if (r == 0) _capturedFrame = Camera.SourceBuffer; return r; });//RawToRgb+取缓冲原子(同一锁内)
                                 HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[rawToRgb结果:{GetRgbData}][注:0表示成功]", LogEnum.HouseRunRecord);
                                 if (GetRgbData != 0)
                                 {
@@ -2348,7 +2429,7 @@ namespace ivf_tl_Com
                                 }
                                 else
                                 {
-                                    Camera.UpdataSourceBuffer();
+                                    CamGate(() => Camera.UpdataSourceBuffer());
                                 }
                             }
                             else
@@ -2364,7 +2445,7 @@ namespace ivf_tl_Com
                             }
                             else
                             {
-                                GetRgbData = Camera.GetRgbData();//获取字节流
+                                GetRgbData = CamGate(() => { int r = Camera.GetRgbData(); if (r == 0) _capturedFrame = Camera.SourceBuffer; return r; });//抓图+取缓冲原子(同一相机锁内,防别舱改pDest致AV崩溃)
                             }
                             HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[第{i}次抓拍结果:{GetRgbData}][注:0表示成功]", LogEnum.HouseRunRecord);
                             if (GetRgbData != 0)
@@ -2373,11 +2454,11 @@ namespace ivf_tl_Com
                             }
                             else
                             {
-                                Camera.UpdataSourceBuffer();
+                                CamGate(() => Camera.UpdataSourceBuffer());
                             }
                         }
                     }
-                    if (GetRgbData == 0 && Camera.CheckRgbData())
+                    if (GetRgbData == 0 && CamGate(() => Camera.CheckRgbData()))
                     {
                         HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[第{i}次抓拍校验成功]", LogEnum.HouseRunRecord);
                         rsData = 0;
@@ -2462,6 +2543,8 @@ namespace ivf_tl_Com
             bool isSuccess = true;
             try
             {
+                lock (_captureCriticalGate)   // D2-02 崩溃修复:拍照关键段(抓图→存DB→存图Save2→上传)跨舱串行,防 coreclr 堆破坏 AV
+                {
                 content = $"[{House.houseSn}][{PortName}][{customProtocol.commandNumber}][{customProtocol.commandType.ToString()}][图片ID:{customProtocol.pictureId}][图片位置:{customProtocol.well}-{currentPicNum}][水平电机:{customProtocol.HorizontalMotorPulse}][垂直电机:{customProtocol.VerticalMotorPulse}][下位机水平:{currentHorizontalMotor}][下位机垂直:{currentVerticalMotor}]";
                 if (customProtocol.HorizontalMotorPulse != currentHorizontalMotor || customProtocol.VerticalMotorPulse != currentVerticalMotor)
                 {
@@ -2490,8 +2573,17 @@ namespace ivf_tl_Com
                 string fileName = $"{House.houseSn}_{itemEmbryo.wellSn}_{Dish.id}_{itemEmbryo.id}_{DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss")}_{customProtocol.HorizontalMotorPulse}_{customProtocol.VerticalMotorPulse}.jpg";
                 string fullName = $"{path}{fileName}";
                 var imageDTO = GetImageDTO(itemEmbryo, fileName, path, savePictureTime.ToString("yyyy-MM-dd HH:mm:ss"), 0, allPicNum, currentPicNum, customProtocol.VerticalMotorPulse, customProtocol.HorizontalMotorPulse, positionType == 1 ? 1 : 0, isEnd);
+                HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][诊断:存DB记录前 {fileName}]", LogEnum.HouseRunRecord);
                 SavePicDbEvent?.Invoke(imageDTO);
-                int saveResult = SaveImage(Camera.SourceBuffer, House.ccdWidth, House.ccdHeight, fullName);
+                HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][诊断:存DB记录完,本地存图前]", LogEnum.HouseRunRecord);
+                byte[] srcBuf = _capturedFrame;   // D2-02 崩溃修复:用抓图时同一锁内原子拷好的快照,不再实时读 Camera.SourceBuffer(防并发AV崩溃)
+                if (srcBuf == null)
+                {
+                    TLLogEvent?.Invoke($"{content}抓图缓冲为空,跳过存图", LogEnum.RunError);
+                    return;
+                }
+                int saveResult = SaveImage(srcBuf, House.ccdWidth, House.ccdHeight, fullName);
+                HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[{House.houseSn}][{PortName}][诊断:本地存图完 结果={saveResult}]", LogEnum.HouseRunRecord);
                 endElapsed = CCDOperateStopwatch.Elapsed;
                 HouseLogEvent?.Invoke(House.houseSn, DateTime.Now, $"[图片保存耗时:{StringHelper.TimeToString(endElapsed - startElapsed)}][图片保存结果:{saveResult}][注:0表示失败,-1表示异常]", LogEnum.HouseRunRecord);
                 if (saveResult != 1)
@@ -2507,6 +2599,7 @@ namespace ivf_tl_Com
                 }
                 imageDTO.ImageData = ByteString.CopyFrom(imageBytes);
                 UploadImageEvent?.Invoke(imageDTO);
+                }   // end lock(_captureCriticalGate)
             }
             catch (Exception ex)
             {
@@ -2540,7 +2633,8 @@ namespace ivf_tl_Com
         {
             try
             {
-                IntPtr ptr = AivfoHelper.ImageProcessing(imgData, width, height, path, sourcePath, isSave, wellname, devTime, leftOffset, bttOffset);
+                // D2-02 崩溃修复:ImageProcessing 同属 Project2.dll,与抓图/Save2 走同一把全局相机锁串行。
+                IntPtr ptr = CamGate(() => AivfoHelper.ImageProcessing(imgData, width, height, path, sourcePath, isSave, wellname, devTime, leftOffset, bttOffset));
 
                 if (ptr == IntPtr.Zero)
                 {
@@ -2570,7 +2664,9 @@ namespace ivf_tl_Com
         {
             try
             {
-                int a = AivfoHelper.Save2(bitmapData, width, height, fileName);
+                // D2-02 崩溃修复:Save2 属 Project2.dll(原生图像库),与 mvcapi 抓图共享全局原生状态,
+                // 必须与抓图走同一把全局相机锁串行,否则别舱抓图并发时撞原生状态致 AV 崩溃。
+                int a = CamGate(() => AivfoHelper.Save2(bitmapData, width, height, fileName));
                 return a;
             }
             catch (Exception ex)

+ 28 - 0
ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs

@@ -699,6 +699,7 @@ namespace ivf_tl_Control
             houseBin.ExceptionLogEvent += LogService.ExceptionLog;
             houseBin.GetAutoFocusServiceEvent += HouseBin_GetAutoFocusServiceEvent;
             houseBin.GetAutoFocusDBEvent += HouseBin_GetAutoFocusDBEvent;
+            houseBin.UploadAutoFocusEvent += HouseBin_UploadAutoFocusEvent;   // D2-02 闭环:本地FocusZ上报服务器排层
             // M2-04:注入标定结果存储入口(写 JSON 真相源 + 镜像库),对焦逐 well 标定后调用。
             houseBin.AutofocusStore = AutofocusStore;
             houseBin.GetCCDServiceEvent += HouseBin_GetCCDServiceEvent;
@@ -896,6 +897,33 @@ namespace ivf_tl_Control
             return HouseBinController.GetAutoFocusController(positionRequestDTO);
         }
 
+        /// <summary>
+        /// D2-02 闭环:把本地四步对焦算出的 FocusZ 上报服务器(calAutofocusPosition)。
+        /// 服务器据此(无评分)以 FocusZ 为中心排各拍照层写表;control 随后用同一 autofocusTime 经 /ccd/position 取回拍照层。
+        /// </summary>
+        private bool HouseBin_UploadAutoFocusEvent(int houseSn, int wellSn, int focusZ, string autofocusTime, int mattingSuccessNumber)
+        {
+            try
+            {
+                var dto = new CalAutoFocusPositionRequestDTO
+                {
+                    tlSn = TLSetting.tlSn,
+                    houseSn = houseSn,
+                    wellSn = wellSn,
+                    clearPosition = focusZ,
+                    autofocusTime = autofocusTime,
+                    mattingSuccessNumber = mattingSuccessNumber,
+                    embryoId = 1,
+                };
+                return HouseBinController.UploadAutoFocusController(dto);
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "HouseBin_UploadAutoFocusEvent");
+                return false;
+            }
+        }
+
         /// <summary>
         /// 从数据库获取自动对焦位置
         /// </summary>

+ 33 - 0
ivf_tl_operate_2.0/control/ivf_tl_Controller/HouseBinController.cs

@@ -126,6 +126,39 @@ namespace ivf_tl_Controller
             }
         }
 
+        /// <summary>
+        /// D2-02 闭环:上报机旁本地对焦结果(FocusZ)给服务器 calAutofocusPosition。
+        /// 服务器收到后据此排"清晰层 + 上下对称拍照层"写表;control 随后用同一 autofocusTime 经 /ccd/position 取回拍照层。
+        /// </summary>
+        /// <returns>是否上报成功</returns>
+        public bool UploadAutoFocusController(CalAutoFocusPositionRequestDTO dto)
+        {
+            string funcName = "UploadAutoFocusController";
+            try
+            {
+                string url = $"/api/tl/control/autofocus/cal/position";
+                string body = JsonConvert.SerializeObject(dto);
+                string apiResultString = _httpService.callWebService(url, body);
+                if (string.IsNullOrEmpty(apiResultString))
+                {
+                    ErrorLog($"{funcName}上报无响应 house={dto.houseSn} well={dto.wellSn}", LogEnum.RunError);
+                    return false;
+                }
+                HttpResult<object> apiResult = JsonConvert.DeserializeObject<HttpResult<object>>(apiResultString);
+                if (apiResult == null || !apiResult.success)
+                {
+                    ErrorLog($"{funcName}上报失败 house={dto.houseSn} well={dto.wellSn} 返回:{apiResultString}", LogEnum.RunError);
+                    return false;
+                }
+                return true;
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, funcName);
+                return false;
+            }
+        }
+
         #region 服务器操作
         /// <summary>
         /// 服务器-->获取自动对焦起点数据

+ 31 - 0
ivf_tl_operate_2.0/control/ivf_tl_Entity/DTO/ApiRequestDTO/CalAutoFocusPositionRequestDTO.cs

@@ -0,0 +1,31 @@
+namespace IvfTl.Control.Entity.DTO.ApiRequestDTO
+{
+    /// <summary>
+    /// D2-02 闭环:上报机旁本地对焦结果(FocusZ)给服务器 calAutofocusPosition 的请求体。
+    /// 字段名须与 Java CalAutoFocusPositionParamDTO 一致(tlSn/houseSn/wellSn/clearPosition/autofocusTime/mattingSuccessNumber/embryoId)。
+    /// 服务器收到后(无评分)直接以 clearPosition=FocusZ 为中心排"清晰层+上下对称拍照层"写 t_house_photograph_setting。
+    /// </summary>
+    public class CalAutoFocusPositionRequestDTO
+    {
+        /// <summary>tl 设备 SN</summary>
+        public string tlSn { get; set; }
+
+        /// <summary>舱室 SN</summary>
+        public int houseSn { get; set; }
+
+        /// <summary>well SN</summary>
+        public int wellSn { get; set; }
+
+        /// <summary>最清晰位置 = 本地四步对焦算出的 FocusZ(须与 shootingPosition 同脉冲基准)</summary>
+        public int clearPosition { get; set; }
+
+        /// <summary>本次自动对焦时间戳(批次键,格式 yyyy-MM-dd HH:mm:ss,须与 /ccd/position 取层用的时间戳一致)</summary>
+        public string autofocusTime { get; set; }
+
+        /// <summary>本地对焦成功层数(原云端抠图成功数;新系统无评分,合格即传足够大值以越过服务器更新阈值)</summary>
+        public int mattingSuccessNumber { get; set; }
+
+        /// <summary>胚胎 id(服务器当前固定按 1 处理,占位)</summary>
+        public long embryoId { get; set; }
+    }
+}

+ 12 - 0
项目文档/进度/D2-02-第三阶段-自动对焦重构-特殊情况记录.md

@@ -115,3 +115,15 @@
 - 影响面/闭环核对:核心调试页闭环(进页面自动标定→看每孔状态/MJPEG画面→逐孔微调→存范围→手动兜底)不依赖这些旧抓图流程;它们失效不阻断主闭环。
 - 待用户回头确认:★旧"手动抓图/水平16孔抓图"在新调试页是否还要保留——要则需做抓图协作(control抓帧→HTTP回传),不要则删。默认暂留(失效但不碍主闭环),待 UI 细化时定。 → ✅**用户已确认(2026-06-25):不要了,删除旧抓图按钮/流程(Task3.7)。**
 
+
+#### [Phase5·真机联调] ★多舱并发拍照崩溃——根因=相机分辨率被缓存成默认值致 Save2 越界(dump实证) + 修复  — 2026-06-26
+- 背景:真机联调把自动对焦端到端打通后,**多舱(2/4/6/8)同时对焦+拍照时 control 必崩**(AppCrash,WER 故障模块 `coreclr.dll`,异常 `c0000005`,偏移每次都是同一个 `0x1d4089`)。单舱能正常出图,舱越多越快崩。
+- 排查过程(逐项排除,避免缝补):先后排除了——共享 pDest/static 缓冲、相机实例被多舱共用、原生调用上锁缺口(抓图/Save2/ImageProcessing/Usb2 全补了 CamGate)、日志 LogService(每类各自锁,线程安全)、全量操作日志 OperationLogger(关掉热开关后仍崩,只是寿命延长→排除)、对焦图像处理 WellDetector/Sharpness/ExposureMeter(纯托管C#,不碰原生)。静态推断到顶仍未中,遂上 **createdump 抓 1.1GB 完整内存 dump + dotnet-dump 分析托管栈**。
+- **根因(dump 实证,非猜)**:崩溃线程栈 = `Save2(byte[],int,int,string)`(Project2.dll)← SaveImage(HouseBin.cs)← Photograph ← ccdThreadFun。看该帧实参:`width=0x0a20=2592, height=0x0798=1944`(House.ccdWidth/Height),但传入的 `byte[]` 用 dumpobj 看实际只有 **5,760,000 字节 = 1600×1200×3**;而 Save2 按 2592×1944 要读 **15,116,544 字节** → **越界读 ~9.4MB → 砸坏托管堆 → coreclr 在固定偏移 AV**。
+- **为何缓冲是 1600×1200**:合并后相机改由 HAL 按 index 缓存唯一实例、"首建为准"。开机 `SerialBin.GetCameraSn` 读序列号走了 **默认重载 `GetCamera(i)` = 1600×1200**,先把该相机缓存成了 1600×1200;采集端随后 `GetCamera(ccdId, 2592, 1944, exp)` 要正确分辨率时,HAL 直接返回那台缓存的 1600×1200(忽略了请求的 w/h)。→ `Camera.SourceBuffer = new byte[width*height*3]` 出 1600×1200 的 5.76MB 缓冲,存图却按 2592×1944 算 → 越界。
+- **老代码为何不犯**:老代码不走 HAL,SN 枚举用 `new Camera(i,100,100,100)` 临时实例(读完即弃)、采集用 `new Camera(ccdId, House.ccdWidth, House.ccdHeight, ...)` 单独实例——两台各自独立、采集相机永远是 2592×1944,故稳。**崩溃与"多舱并发"本身无关**(相机原生调用新老都串行:老靠 Camera 内 `static locker`,新靠 HAL `_gate`),只是舱多更易撞上被污染成 1600×1200 的那台相机。
+- **修复**(`HardwareAccessLayer.cs` GetCamera):带分辨率的 `GetCamera(index,w,h,exp)` 发现缓存相机 `Width/Height` 与请求不一致时,**Dispose 旧实例 + 按正确分辨率重建**;默认重载 `GetCamera(index)` 改为"已建则原样返回",绝不把已正确的实例降级。即 HAL 注释本意"采集端用实际 ccdWidth/ccdHeight 创建正确实例"的落实。
+- **验证(真机)**:分辨率修复版部署后,2/4/6/8 四舱同时跑完 16 孔对焦 + 并发拍照,**连续 34+ 分钟、92+ 张图、零崩溃**;抽查图片均 **2592×1944 / ~900KB 全分辨率**(此前崩溃版根本出不了图或出错图);存图目录 `C:\TLData\EmbryosControl`。WER 最后一次崩溃停在抓 dump 那次(01:47),修复后再无新崩。
+- 同批闭环补丁(本次真机联调一并修,均已验证):① StartAutoFocus 对焦时临开临关 LED;② FocusZ 经 `/api/tl/control/autofocus/cal/position` 上报服务器 calAutofocusPosition(补闭环关键缺口,新增 CalAutoFocusPositionRequestDTO + HouseBinController.UploadAutoFocusController + AppData 订阅);③ 无气源时补气提前退出(不拖慢采集);④ FirstClearest 循环退出bug修正;⑤ application-local.properties Feign 名 `aivfo-business-manage-pc-dev` 对齐网关。
+- 影响面/闭环核对:自动对焦→上报→服务器排对称层→取层→拍照→存图→上传 整链在多舱并发下打通且稳定。修复只动 HAL 取相机一处,采集/对焦逻辑未改。
+- 待用户回头处理(用户明确"晚点"):① 去掉本次为排查并发临时加的 `_captureCriticalGate`(拍照关键段跨舱全局锁)——根因既非并发,此锁多余且降低并发度,应回退到老代码并发度;② 多舱对焦节拍优化(相机锁串行下 4 舱对焦约需数十分钟,抓帧串行是瓶颈,电机/等待/计算可并行);③ 把样片按真实目录结构放桌面供查看。

+ 7 - 1
项目文档/进度/D2-02-第三阶段-自动对焦重构-进度.md

@@ -61,8 +61,14 @@
 - [x] 4.2 删/停 AutoFocusWindow + GetAutofocusPicturesApi 消费链 — 全死代码清除(-646行),ivf_tl_Manage.sln Release 0错
 - [x] 4.3 核对看胚胎切焦平面(0 层=焦面+层对称)不受影响 — 未动DetailPicViewModel/CurrentFocal,已核实
 
-### Phase 5 — 真机三门 + 启用安全门  🔴 全部卡真机硬件/需重启清operate.exe(代码就绪,待联调)
+### Phase 5 — 真机三门 + 启用安全门  🟡 真机联调进行中(闭环已通+多舱崩溃已根治,三门待补)
 > 前置:① 重启真机清掉卡死的 operate.exe(PID20268,见特殊情况记录)→ 才能跑真 operate;② 需电机/活体胚胎。代码全就绪,只差真机执行。
+>
+> **★2026-06-26 真机联调进展(详见特殊情况记录同日条目)**
+> - ✅ **自动对焦端到端闭环打通**:本地四步对焦 → FocusZ 上报 calAutofocusPosition → 服务器排上下对称拍照层 → /ccd/position 取层 → 抓帧 → 存图 → 上传,整链跑通。
+> - ✅ **多舱并发拍照崩溃已根治**:根因 dump 实证 = 相机被开机SN枚举缓存成默认 1600×1200,采集端存图按 2592×1944 致 Save2 越界砸堆(coreclr AV 固定偏移)。修 HAL `GetCamera` 分辨率不符即重建。**4舱(2/4/6/8)同时对焦+拍照,34+分钟/92+张图/零崩溃,图均 2592×1944 全分辨率**(存 `C:\TLData\EmbryosControl`)。
+> - ✅ 同批闭环补丁:对焦临开临关LED / 无气源补气提前退出 / FirstClearest循环退出bug / Feign名对齐网关。
+> - ⏳ 待办(用户明确"晚点"):去掉排查期临加的 `_captureCriticalGate`(根因非并发,此锁多余)、多舱对焦节拍优化、样片放桌面。
 - [ ] 5.1 74000 伪峰(范围排除)— 需真机+DebugSave
 - [ ] 5.2 真胚胎峰比阈值(focus_peak_ratio_threshold)— 需活体胚胎
 - [ ] 5.3 EEPROM 4 个手写生效(对焦不写)— 需真机