我要进行心脏超声特征提取实现冠心病预测的任务,现在用EchoNet-Dynamic的开源进行了分割,...

创建于:2026年5月25日

使用 GPT-5.5 Thinking 回答 Chat01

问题

image.png

我要进行心脏超声特征提取实现冠心病预测的任务,现在用EchoNet-Dynamic的开源进行了分割,且自己写了个脚本(放在末尾)提取切面局部节段性室壁收缩特征,结果如图。现在公开项目是面向心衰的,我接下来要进行冠心病,这里总结的特征列表汇报对吗?冠心病有什么需要补充的,如果有有关内容,使用和我现在叙述风格一致的文本进行补充。
心力衰竭特征列表
开源项目基于心尖四腔心(A4C)单切面视频,通过单平面几何估算与视频时空回归提取以下全局宏观指标,用于心力衰竭(HFrEF)的二分类预测:
1.单平面全局几何指标
(1)左心室舒张末期截面积:左心室血池充盈至最大处的单帧像素总数。用于评估是否存在缺血性或特发性心腔整体扩张。
(2)左心室收缩末期截面积:左心室血池收缩至最小处的单帧像素总数。用于评估射血后心腔的残留余量。
(3)左心室整体面积变化率(FAC Proxy):反映二维平面上左心室的整体收缩泵血效率。

2.单平面几何积分容积指标(基于A4C假设的旋转体积分,共3维)
(1)左心室舒张末期估算容积(LVEDV):利用单平面辛普森盘式法,假设心腔横截面为圆形对称,积分算出的舒张末期总容量(单位:mL)。
(2)左心室收缩末期估算容积(LVESV):单平面积分算出的收缩末期残留总容量(单位:mL)。
(3)全局左心室射血分数(LVEF):通过两期容积差率算出的整体心功能评估金标准。

3.时空动态高维视觉特征(视频级黑箱特征,共1维)
时空视频流高维运动特征嵌入向量:由 3D-CNN 网络直接抽取的、代表整段视频心肌纤维在时空中全局运动趋势的高维数学嵌入向量。在数据管线中,该高维特征经提取后执行横向展平(Flatten),化为 512 个连续的连续数值标量字段(即 embedding_0 至 embedding_511),无缝并入全局特征主 .csv 大宽表中。它与自研的白箱几何指标并排存放,以结构化表格的形式供下游机器学习模型直接调度与筛查。


冠心病预测所需特征列表
基于多切面(心尖四腔心 A4C、二腔心 A2C、三腔心 A3C)联合分析与解剖学节段动力学解离,从全局心功能、局部几何形变、时频域信号及临床病史等多维度提取以下特征指标,用于冠心病的精准预测、病变血管定位与风险分层评估:
1.多切面全局常规心功能指标(与心衰预测特征重合)
(1)各切面左心室舒张末期截面积:各切面左心室血池充盈至最大处的单帧像素总数。用于评估冠心病中晚期是否伴随出现缺血性心腔扩张与整体重构。【与心衰特征完全重合】
(2)各切面左心室收缩末期截面积:各切面左心室血池收缩至最小处的单帧像素总数。用于评估局部泵血受损后心腔的残留余量。【与心衰特征完全重合】
(3)各切面左心室整体面积变化率:反映各切面二维平面上左心室的整体收缩效率。用于评估冠心病引发的整体收缩力减退。【与心衰特征完全重合】
(4)单平面几何积分容积与射血分数指标:包含多切面各自估算出的左心室舒张末期容积(LVEDV)、收缩末期容积(LVESV)及全局左心室射血分数(LVEF)。用于评估全心泵血功能受损程度。【与心衰特征完全重合】
(5)时空动态高维视觉特征:由时空神经网络提取的、代表整段视频心肌纤维全局运动趋势的高维数学嵌入向量。【与心衰特征完全重合】

2.多切面局部节段性室壁收缩幅度指标
算法在三个标准切面上执行统一的物理切分管线,将左心室全方位解离为 18个标准解剖节段。各切面的空间解剖学方位对应关系如下:
• A4C(心尖四腔心)切面 6 节段:代表左心室的 后间隔壁 和 外侧壁。
• A2C(心尖二腔心)切面 6 节段:代表左心室的 前壁 和 下壁。
• A3C(心尖三腔心)切面 6 节段:代表左心室的 前间隔壁 和 后壁。
该板块基于各个切面的左心室语义分割轮廓,沿长轴中心参考线将掩膜进行切分,打捞出这 18 个解剖节段心内膜边缘至中心参考线的完整径向距离时间序列曲线。通过捕捉曲线随时间的周期性升高与降低,进一步提炼出以下更细粒度的局部室壁收缩幅度特征:
(1)18节段舒张末期初始径向距离:反映各切面解剖壁段在舒张末期的初始基线几何状态,用以评估不同解剖方位下心腔底部的空间结构。
(2)18节段向内最大收缩位移:每个壁段的内膜边缘在整个心动周期中向左心室中心参考线推进的最大物理像素差。用于定量评估各特定靶向受累节段心肌的主动向内收缩与挤压幅度。
(3)18节段最大局部径向缩短率:各个壁段在收缩末期(ES)相对于舒张末期(ED)的相对向内缩短百分比。这是本模型中用以筛查和捕捉由于冠状动脉狭窄或心肌缺血,引发特定供血区域局部室壁运动减弱(Hypokinesia)或无运动(Akinesia)的绝对核心形态学指标,可将整体心功能指标进一步细化为局部室壁运动特征。

3.多切面局部机械时相与传导延迟指标
(1)18节段收缩达峰绝对帧号:各解剖壁段向内收缩到极致、距离中心参考线最近时对应的视频帧索引。用以初步锁定局部节段机械收缩的结束时相。
(2)18节段收缩达峰绝对物理秒数:代表各壁段的达峰时间(Time-to-Peak Minimum)。用于敏感捕捉因局部心肌供血不足、心肌顿抑或冬眠所引发的局部电-机械传导延迟。

4.多切面心肌局部运动速率与爆发力指标
(1)18节段最大向内收缩速度:通过对节段距离时间序列曲线进行一阶求导获得的最大冲刺斜率。反映局部心肌向内泵血的顺时最快速度,用以量化心肌受损后的顺时顺应性。
(2)18节段最大向内收缩加速度:通过对曲线进行二阶求导获得的速度变化率峰值。反映心肌肌肉纤维瞬间点火并产生爆发力的局部顺应性状态,可用于筛查早期隐匿性心肌缺血。

5.多切面机械运动不同步性指标
(1)各切面单切面机械不同步性指标:计算单个切面内 6 个解剖节段达峰秒数的标准差与极差(最大值与最小值的差值)。用以评估各独立切面内部的局部收缩步调离散度。
(2)跨切面全局联合机械不同步性指标:跨越三个切面、全量 18 个解剖节段达峰秒数的总标准差。用于量化整颗心脏搏动时各区域收缩步调的离散程度,是诊断冠心病区域运动不协同的核心数理依据。

6.多切面心肌壁自身灰度纹理时序特征
本阶段进一步借鉴 CAMUS 双层解剖学分割规范,精准锁定 18 个解剖节段心肌壁肉身所在的环形感兴趣区域(ROI)。将该时序面具重新投影回原始超声灰度视频流中,从组织特征维度进行精细化打捞:
(1)18节段时序灰度均值与方差波形:提取心肌区域原始灰度值随心跳周期演变的时间序列曲线。冠心病引发的心肌慢性缺血与陈旧性纤维化会导致超声回声强度异常增强(回声变亮)。
(2)18节段时序动态纹理特征矩阵:包含各节段心肌区域的灰度共生矩阵(GLCM)时序动态演变指标,用以捕捉心肌组织受损后斑点噪声的无序化异质性。该指标可作为机械形变运动特征的重要组织学补充。

7.时频域高阶微观特征(下一阶段升级指标)
(1)18节段时频子频带能量占比与信息熵:利用小波包分解(Wavelet Packet Decomposition)滤除宏观心跳大趋势后,提取出的多维频段细节能量分布特征。用于精准捕捉缺血或梗死心肌特有的非平稳局部机械微震颤。

8.临床高危基线多模态特征
(1)常规病史危险因素指标:包含患者的实际年龄、生物学性别、高血压病史、高脂血症病史、长期吸烟史及家族早期冠心病遗传史。【与心衰高危因素重合】 (2)糖尿病病史指标:在预测模型中作为“冠心病等危症”对待的绝对核心临床多模态特征。用于大幅提高机器学习模型判定冠脉严重狭窄的预测敏感度。【与心衰高危因素部分重合,但在冠心病预测中具备决定性权重】

思考

undefined

思考

undefined

思考

Structured assessment metrics for echo and stress testing

I’m organizing a list of important metrics for echo and stress testing, adding things like AHA, WMSI, and various measurements such as radial shortening, EF reserve, and ischemic burden. I also need to include quality control factors like confidence, frame rate, and segmentation for artifact avoidance.

Thought for 1m 28s

回答

整体方向是对的,你现在从 EchoNet-Dynamic 的 LV blood-pool 分割出发,把“全局 EF/FAC 类指标”升级到“局部节段性室壁运动曲线”,这个转向非常适合冠心病任务。EchoNet-Dynamic 原始定位确实更偏向左室分割、EF 估计和 HFrEF 分类,而冠心病更依赖区域室壁运动异常、节段供血域聚合、应激前后变化和临床风险因子联合判断。(GitHub)

但你的汇报里有几处建议改一下:

  1. A4C 单切面容积不要写得太像临床标准 LVEF。
    A4C 单切面 Simpson 或旋转体积分可以作为 white-box proxy,但正式 LV 容积推荐通常基于 A4C + A2C 的 biplane Simpson,并且要避免心尖缩短导致容积低估。你可以把它写成“单平面估算容积 / LVEF Proxy”,多切面后再升级为“双平面或多平面联合估算容积”。(ASE)

  2. “18 个标准解剖节段”建议改成“18 个切面投影节段”。
    临床通用是 AHA 16/17 节段模型,17 节段模型还包含 apical cap,常用于灌注或跨模态比较。你现在的 A4C/A2C/A3C 每个切面 6 段很适合算法提特征,但它更准确的叫法是“18 个 view-segments / 投影节段”,后续需要映射回 AHA 16/17 节段和 LAD、LCx、RCA 供血域。(ASE)

  3. 冠心病不能只依赖静息收缩幅度。
    静息 RWMA 对陈旧梗死、明显狭窄或冬眠心肌有价值,但稳定冠心病在静息状态可能没有明显异常。应激超声中,缺血反应的经典定义是“原本正常节段在负荷下出现新的室壁运动异常,或原本异常节段在负荷下进一步恶化”。所以如果你没有负荷/应激视频,建议表述为“冠心病风险预测 / 供血域受累提示”,不要写成“病变血管精准定位”的临床定论。(ScienceDirect)

  4. 灰度纹理部分可以保留,但要加质量控制。
    超声灰度受增益、TGC、探头频率、深度、压缩、机器厂商影响很大。灰度纹理可作为组织学补充,但最好同时加入 acquisition metadata、图像质量分数、分割置信度、foreshortening 评分、帧率归一化等控制变量。

下面这段可以直接补进你的“冠心病预测所需特征列表”里,风格尽量按你原文延续。


冠心病预测所需补充特征列表

9.标准化节段壁运动评分与冠脉供血域聚合指标
在完成 18 个切面投影节段的局部径向距离曲线提取后,进一步将各节段映射至 AHA 16/17 标准左心室节段模型,并按照 LAD、LCx、RCA 三大冠状动脉供血区域进行区域聚合。该模块用于把“单个像素级曲线异常”升级为“临床可解释的供血域异常模式”,从而提高冠心病任务中的病变血管归因能力。AHA 17 节段模型是跨超声、核医学、CT、MRI 等心脏影像常用的标准化节段命名体系,但冠脉供血存在个体解剖差异,因此模型输出应表述为“疑似供血域受累概率”,而非绝对病变血管诊断。(ScienceDirect)

(1)标准化节段壁运动评分:将每个节段的局部径向缩短率、向内收缩速度、达峰时间及后收缩异常综合转化为正常、运动减弱、无运动、反向运动等分级评分。用于构建类临床 Wall Motion Score 的白箱解释指标。
(2)左心室整体壁运动评分指数(WMSI):对全部标准化节段评分进行平均,得到全局壁运动异常负荷。该指标可作为 LVEF 之外的冠心病风险分层指标,尤其适用于 LVEF 尚未明显下降但局部节段已经异常的患者。
(3)LAD 供血域异常负荷:聚合前壁、前间隔、心尖相关节段的缩短率下降、达峰延迟和速度下降程度,用于提示左前降支相关缺血或梗死风险。
(4)LCx 供血域异常负荷:聚合外侧壁、后外侧壁或下外侧壁相关节段的运动异常强度,用于提示左回旋支相关供血受累。
(5)RCA 供血域异常负荷:聚合下壁、下间隔及部分后壁相关节段的运动异常强度,用于提示右冠状动脉或后降支相关供血受累。
(6)连续异常节段数量:统计同一供血域内相邻异常节段的数量与空间连续性。冠心病导致的节段异常通常具有供血域聚集特征,该指标可用于区分真实区域性缺血与单个切面分割噪声。

10.静息—负荷差分型缺血诱发指标
若后续数据集中包含运动负荷、药物负荷或应激超声视频,应在静息状态与峰值负荷状态之间构建差分特征。该板块是冠心病预测中最具疾病特异性的升级模块,因为冠心病的核心病理并非单纯静息泵血下降,而是在心肌耗氧增加时,受狭窄冠脉供血限制而诱发区域性运动恶化。应激超声的缺血判读核心正是新发或加重的区域室壁运动异常。(ScienceDirect)

(1)18节段负荷后径向缩短率下降量:计算峰值负荷状态相对于静息状态的局部缩短率差值。用于捕捉静息尚可、负荷后暴露的诱发性缺血。
(2)18节段负荷后达峰时间延迟量:计算各节段在负荷前后收缩达峰时间的变化。缺血节段在负荷状态下可能出现机械收缩延迟或收缩终止不完全。
(3)18节段负荷后最大收缩速度衰减量:衡量局部心肌在供氧压力增加时是否出现向内运动速度下降。
(4)负荷诱发 WMSI 增量:计算负荷后 WMSI 相对于静息 WMSI 的增幅,用于表示诱发性缺血总负荷。
(5)左心室收缩储备指标:计算负荷后 LVEF、FAC Proxy、ESV 或局部缩短率是否按预期改善。若负荷后收缩储备不足、ESV 不降反升或局部节段运动恶化,可作为高危冠心病提示信号。

11.心肌应变替代指标与后收缩异常指标
在现有内膜边界径向距离曲线的基础上,进一步构建类 strain 的时序形变指标。相比单纯“距离缩短多少”,应变类指标更关注“同一节段在心动周期中相对自身初始长度发生了多少形变”,可提高跨患者、跨切面、跨设备的可比性。既往研究与指南性文件均强调,纵向应变、早期收缩拉长和后收缩缩短可用于量化常规肉眼壁运动难以识别的细微缺血性形变异常。(Heart)

(1)18节段局部径向应变 Proxy:以舒张末期径向距离为基线,计算整个心动周期内的相对缩短曲线。用于消除不同患者心腔大小、探头放大倍数和切面尺度差异。
(2)全局纵向应变 Proxy:基于心尖至瓣环长轴长度变化、节段中心线运动和内膜轮廓时序形变,构建近似 GLS 的白箱替代指标。用于捕捉 LVEF 正常但心肌纵向收缩已经受损的早期冠心病信号。
(3)18节段后收缩缩短量(PSS):识别主动脉瓣关闭或全局收缩末期之后仍继续向内缩短的节段。缺血心肌常出现收缩时相滞后,后收缩缩短可作为局部缺血、顿抑或冬眠心肌的敏感动力学信号。
(4)18节段后收缩指数(PSI):计算收缩末期之后额外缩短量占总缩短量的比例。该指标用于把“达峰延迟”进一步定量化,区分单纯慢收缩与真正的收缩后异常。
(5)早期收缩拉长指数(ESL):统计收缩早期节段反而向外扩张或长度增加的异常形变幅度。该指标可用于捕捉缺血心肌在等容收缩期出现的反常被动拉伸现象。

12.心肌壁厚度与收缩期增厚指标
当前 EchoNet-Dynamic 输出主要是左心室血池语义分割,因此更适合计算内膜运动和心腔面积变化;但冠心病超声判读不仅看内膜是否向内运动,也看心肌壁自身是否在收缩期增厚。建议下一阶段引入心外膜边界或心肌环形 ROI 分割,将“血池边界运动”升级为“心肌壁真实增厚与变薄”的组织级特征。

(1)18节段舒张末期心肌壁厚度:测量各节段心内膜至心外膜的局部厚度。用于识别陈旧性心肌梗死后局部壁变薄、瘢痕化或室壁重构。
(2)18节段收缩期壁增厚率:计算收缩末期相对于舒张末期的局部心肌厚度增加比例。冠心病缺血节段可表现为内膜运动幅度下降,同时收缩期壁增厚不足。
(3)局部变薄伴高回声复合特征:将壁厚度下降、灰度均值升高、纹理异质性增强进行联合建模。用于提示陈旧性梗死、纤维化或瘢痕心肌。
(4)内膜运动—心肌增厚解耦指标:比较同一节段的内膜向内位移与心肌壁增厚是否一致。若内膜被邻近正常节段牵拉而出现假性运动,但心肌壁自身增厚不足,可提示 tethering 效应或局部主动收缩能力下降。

13.冠脉血流储备与心肌灌注补充指标
若后续数据来源不仅包含普通 B-mode 视频,还包含多普勒、造影或应激超声协议,可进一步补充血流与灌注维度。冠心病的根本问题是冠脉供血不足,室壁运动异常是供血不足后的力学表现,因此血流储备与灌注特征能够为模型提供更靠近病因层的证据。欧洲应激超声 ABCDE 协议中,区域壁运动、肺 B 线、左室收缩储备、LAD 冠脉血流速度储备和心率储备被组合用于更全面的风险分层。(OUP Academic)

(1)LAD 冠脉血流速度储备(CFVR-LAD):在静息与负荷状态下测量 LAD 血流速度变化,用于评估冠脉微循环与大血管供血储备能力。
(2)心肌造影灌注充盈速度:基于超声造影剂在局部心肌 ROI 内的再充盈曲线,提取灌注速度、峰值强度和达峰时间。用于捕捉室壁运动异常出现之前的区域灌注不足。
(3)灌注—运动不匹配指标:若某供血域灌注下降但运动尚正常,可提示早期可逆性缺血;若灌注下降且运动异常,则提示更高危的供血受限或既往损伤。
(4)肺 B 线负荷反应指标:负荷后肺 B 线增加可提示运动诱发的充盈压升高和心肺储备下降,可作为冠心病风险分层的辅助维度。

14.临床似然度与冠脉标签监督指标
冠心病预测不能只依赖图像特征。建议将图像白箱特征、黑箱视频嵌入、临床危险因素、症状学信息、心电图和冠脉影像标签整合为统一多模态学习任务。ESC 2024 慢性冠脉综合征指南强调,阻塞性冠心病的评估应结合年龄、性别、症状和危险因素构建风险因子加权的临床似然度模型,而不是只看单一影像指标。(European Society of Cardiology)

(1)胸痛症状学指标:包含典型心绞痛、非典型胸痛、劳力性胸闷、呼吸困难、静息痛、症状持续时间及诱发缓解因素。用于构建冠心病预测的临床先验概率。
(2)静息心电图异常指标:包含 ST-T 改变、病理性 Q 波、束支传导阻滞、房颤、室早及既往心梗提示性改变。用于补充超声无法直接观察到的电生理缺血痕迹。
(3)实验室危险因素指标:包含 LDL-C、HbA1c、空腹血糖、肌酐/eGFR、hs-CRP、肌钙蛋白及 BNP/NT-proBNP。用于捕捉动脉粥样硬化负荷、糖代谢异常、肾功能风险和心肌损伤背景。
(4)冠脉 CTA / CAG / FFR 标签:将模型标签从单纯“是否冠心病”细化为是否存在阻塞性冠脉狭窄、狭窄程度分级、病变血管类别、单支/双支/三支病变、左主干受累及功能学缺血证据。用于提升模型从“风险筛查”到“病变血管定位”的监督质量。
(5)患者级数据切分指标:所有视频、切面和心动周期必须按患者级划分训练集、验证集和测试集,避免同一患者不同切面泄漏导致模型性能虚高。

15.图像质量控制与跨设备归一化指标
冠心病模型比心衰 EF 模型更容易受到切面角度、心尖缩短、帧率、分割噪声和机器参数干扰。因此建议单独建立质量控制特征层,将“可解释生理异常”与“采集伪差”尽量拆开,避免模型把设备差异、增益差异或切面差异误学成冠心病特征。

(1)切面分类置信度:分别记录 A4C、A2C、A3C 的自动切面识别概率,剔除非标准切面或严重偏轴视频。
(2)心尖缩短评分:评估心尖是否被截短、长轴是否充分展开。心尖缩短会导致 LV 容积和局部运动幅度系统性低估。(ASE)
(3)分割置信度与轮廓稳定性:记录每帧 LV mask 面积突变、边界抖动、缺失帧比例和时序平滑残差。用于识别由分割失败导致的假性节段运动异常。
(4)帧率与心动周期归一化:将绝对帧号转换为心动周期百分比,将速度和加速度按真实秒数、心率和 RR 间期进行归一化。用于保证不同视频帧率和心率下的达峰时间、速度、加速度具有可比性。
(5)像素—物理尺度归一化:若 DICOM 中可获得像素间距,应将径向距离由 pixel 转换为 mm;若仅有 AVI,则至少用 LV 长轴长度、舒张末期面积或体表面积进行尺度归一化。
(6)超声机器参数记录:包含设备厂商、探头频率、深度、增益、动态范围、压缩参数和是否使用谐波成像。用于降低灰度纹理特征在跨中心训练中的域偏移风险。


你现在这张图里的 6 段径向距离曲线,已经能支撑“局部节段收缩幅度、达峰时间、速度、不同步性”这几个白箱指标;下一步最关键的是把它从“单切面曲线”提升成“标准节段—供血域—患者级标签”的三层结构。也就是说,算法层面继续保留 18 个投影节段,但汇报和建模输出层面建议落到 AHA 16/17 节段 + LAD/LCx/RCA 供血域 + WMSI/应激差分/PSI 这一套更贴近冠心病临床语义的体系。

问题

我在通过脚本提取自有超声视频的ESED帧,为了提供给医生绘制掩膜,我做了下面这样的脚本,ED帧我注意到每个视频的开头都是心电图最高点,但视频开始时也更容易出现探头还未稳定的情况以至于质量可能不如下一轮心动周期的质量,但是正常对心电图进行提取还会出现心电图最高点寻找不准的问题,需要寻找更合理的办法。txt文档是每个人都有的,但并不是和视频一一对应的,一个患者会有多个视频,且我将原始视频转换为avi后存在播放速度差异,直接用出现在多少秒来判断是不可行的,视频帧率普遍为50帧。基于这种情况,有什么更好的处理关键帧提取的办法吗(有A2CA3CA4C,无法通过开源项目分割求面积最大最小值的方式寻找A3C,所以打算寻找能够通用的通过手上的心电图和txt进行提取的办法,除非有开源项目也在A3C训练过,你能提供给我也行),修改的代码通过代码窗口发送给我,不要直接生成文件,我接收不到。完成这个帧提取工作后,再为我提供绘制掩膜的工具安装使用指导。

-- coding: utf-8 --

"""
dump_calibration_frames.py (多切面均衡抽样与关键帧统一归拢汇总版)
"""

import os
import math
import argparse
from pathlib import Path
import cv2
import numpy as np
import pandas as pd

def get_video_meta(video_path: Path):
cap = cv2.VideoCapture(str(video_path))
fps = cap.get(cv2.CAP_PROP_FPS)
if fps is None or fps <= 1 or fps > 300 or math.isnan(fps): fps = 50.0
n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
return fps, n, w, h

def read_frame(video_path: Path, frame_idx: int):
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened(): return None
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ok, frame = cap.read()
cap.release()
return frame if ok else None

def parse_strain_txt_for_es_time(folder_path: Path):
"""
高鲁棒性应变数据解析器:自动扫描原始路径下的 Results TXT 报告,
提取 Longitudinal Strain -> Total 负向应变最低谷,锁定真实收缩耗时(ms)
"""
txt_files = list(folder_path.glob("Results.txt"))
if not txt_files:
txt_files = list(folder_path.glob("*.txt"))

text
for txt_p in txt_files: try: with open(txt_p, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() time_vals = None total_vals = None in_strain_section = False for line in lines: if "Longitudinal Strain" in line: in_strain_section = True continue if in_strain_section and "Time [ms]" in line: parts = line.strip().split(';') time_vals = [float(p) for p in parts if p and not any(c.isalpha() for c in p)] if in_strain_section and line.startswith("Total;"): parts = line.strip().split(';') total_vals = [float(p) for p in parts if p and p != "Total"] break if time_vals and total_vals: min_idx = np.argmin(total_vals) t_es = time_vals[min_idx] return t_es, txt_p.name except Exception: continue return None, None

def main():
success_log = Path(r"D:\echo_autolabel\success_log.csv")
accepted_root = Path(r"D:\echo_project\data\pseudo_labeled_v6_final\Accepted")

text
# ─── 🎯 升级点 1:定义全量平铺路径与新增的“独立统一汇总文件夹” ─── calibration_out = Path(r"D:\echo_project\data\latency_calibration") summary_out = Path(r"D:\echo_project\data\latency_calibration_summary") summary_out.mkdir(parents=True, exist_ok=True) print("\n========================================================================") print("🎬 课题组自动化校准引擎:[90例多切面均衡抽样与关键帧归拢模式] 启动!") print(f"📂 归拢汇总靶点文件夹: {summary_out}") print("========================================================================\n") if not success_log.exists(): print(f"❌ 运行中止:找不到路径对账表success_log.csv: {success_log}") return df_log = pd.read_csv(success_log) df_log['New_Filename'] = df_log['New_Filename'].astype(str).str.strip() df_log['Original_File_Path'] = df_log['Original_File_Path'].astype(str).str.strip() mapping = dict(zip(df_log['New_Filename'], df_log['Original_File_Path'])) total_processed = 0 # ─── 🎯 升级点 2:三切面循环隔离,确保每个切面独立且精准地只取前 30 个 ─── for view in ["A2C", "A3C", "A4C"]: view_dir = accepted_root / view if not view_dir.exists(): print(f"⚠️ 警告:未检测到切面文件夹 {view_dir},自动跳过。") continue avi_files = sorted(list(view_dir.glob("*.avi"))) print(f"🔍 正在检索切面 【{view}】... 该文件夹下共有 {len(avi_files)} 个视频") view_count = 0 max_per_view = 30 # 每个切面严格硬性限制前 30 个合格样本 for v_path in avi_files: if view_count >= max_per_view: print(f" 🛑 已刷满该切面前 {max_per_view} 个样本额度,停止向下检索。") break new_name = v_path.name if new_name not in mapping: continue orig_path = Path(mapping[new_name]) if not orig_path.exists(): continue print(f" ▶️ [{view} - 进度: {view_count+1}/{max_per_view}] 正在攻坚病例: {new_name}") # 1. 提取物理帧元数据 clean_fps, clean_n, _, _ = get_video_meta(v_path) orig_fps, orig_n, orig_w, orig_h = get_video_meta(orig_path) # 2. 时序电信号起点强制定为第 0 帧 ed_orig_pred = 0 # 3. 通过同级目录下的 Results.txt 曲线提取毫秒级 ES 耗时 t_es, txt_name = parse_strain_txt_for_es_time(orig_path.parent) if t_es is not None: es_offset_frames = int(round(t_es * orig_fps / 1000.0)) es_orig_pred = int(np.clip(ed_orig_pred + es_offset_frames, 0, orig_n - 1)) else: # 缺少txt时采用75bpm标准时脑盲切兜底 es_offset_frames = int(round(0.38 * 0.8 * orig_fps)) es_orig_pred = int(np.clip(ed_orig_pred + es_offset_frames, 0, orig_n - 1)) # 4. 执行全量平铺导出 video_folder = calibration_out / orig_path.stem video_folder.mkdir(parents=True, exist_ok=True) cap = cv2.VideoCapture(str(orig_path)) frame_idx = 0 ed_frame_img = None es_frame_img = None while True: ok, frame = cap.read() if not ok: break suffix = "" if frame_idx == ed_orig_pred: suffix = "_ED" ed_frame_img = frame.copy() # 缓存以便后续统一复制归拢 elif frame_idx == es_orig_pred: suffix = "_ES" es_frame_img = frame.copy() # 缓存以便后续统一复制归拢 img_name = f"frame_{frame_idx:04d}{suffix}.png" cv2.imwrite(str(video_folder / img_name), frame) frame_idx += 1 cap.release() # ─── 🎯 升级点 3:强行跨路径提取核心两帧,复制并重命名到单独汇总文件夹 ─── if ed_frame_img is not None: cv2.imwrite(str(summary_out / f"{orig_path.stem}_{view}_ED.png"), ed_frame_img) if es_frame_img is not None: cv2.imwrite(str(summary_out / f"{orig_path.stem}_{view}_ES.png"), es_frame_img) view_count += 1 total_processed += 1 print(f"\n========================================================================") print(f"🎉 集群大获全胜!全库累计收割并均衡处理完毕:{total_processed} 个病例视频。") print(f"💡 提示:您可以直接前往以下文件夹,一屏无伤复核这 90 个病例的关键帧对齐效果:") print(f"📂 汇总路径: {summary_out}") print("========================================================================")

if name == "main":
main()

Curves

Longitudinal Strain
Unit [%]
Time [ms];0;19.97;39.94;59.91;79.88;99.85;119.83;139.8;159.77;179.74;199.71;219.68;239.66;259.63;279.6;299.57;319.54;339.51;359.48;379.46;399.43;419.4;439.37;459.34;479.32;499.29;519.26;539.23;559.2;579.17;599.15;619.12;639.09;659.06;679.03;699;718.97;738.95;758.92;778.89;798.86;
Total;0;-0.31;-0.27;0.09;0.24;-0.36;-1.34;-2.28;-3.13;-4.23;-5.37;-6.6;-7.71;-8.41;-8.86;-9.29;-9.41;-9.09;-8.62;-8.37;-8.17;-8.04;-7.72;-7.12;-6.29;-5.4;-4.55;-3.86;-3.29;-2.79;-2.37;-2.12;-1.95;-1.82;-1.56;-1.13;-0.57;0.11;0.67;0.99;0.83;
Segment 1;0;-0.72;0.77;0.92;0.4;-1.71;-3.58;-3.58;-2.68;-1.38;0.57;2.91;5.5;8.65;11.8;15.44;18.73;20.1;21.49;23.72;25.42;26.39;25.96;24.72;22.95;21.57;20.84;21.24;21.89;22.17;22.33;22.34;22.26;22.37;20.45;15.72;10.15;7.26;6.79;7.98;7.59;
Segment 2;0;-0.43;-0.31;0.91;1.95;1.16;-0.26;-0.74;-1.37;-2.56;-4.15;-6.24;-8.32;-9.92;-10.98;-11.54;-11.62;-11.38;-10.97;-11;-11.09;-11.01;-11.15;-10.99;-10.68;-10.22;-9.3;-8.41;-7.32;-6.61;-5.98;-5.52;-5.1;-4.95;-4.56;-3.58;-2.19;-0.61;0.74;1.39;1.42;
Segment 3;0;-0.09;-0.35;-0.81;-1.2;-1.2;-1.3;-1.86;-2.54;-3.1;-3.49;-3.63;-3.79;-4.39;-4.9;-5.01;-4.88;-4.76;-5.15;-6.77;-8.42;-9.05;-9.32;-10.12;-10.36;-9;-7.31;-6.86;-6.78;-6.36;-5.92;-5.8;-5.76;-5.6;-4.97;-3.69;-2.34;-1.33;-0.75;-0.36;-0.15;
Segment 4;0;2.31;9.26;14.34;18.48;21.09;24.62;29.64;34.71;36.34;37.05;37.02;37.35;37.99;38.39;37.91;38.09;39.45;41.01;39.77;39.33;42.92;46.21;44.87;42.23;40.71;39.5;38.91;38.68;38.34;38.49;37.91;37.51;35.62;32.97;28.46;22.41;15.17;9.52;11.69;10.86;
Segment 5;0;-1.01;-1.82;-0.72;0.58;0.64;0.03;-0.21;-0.13;-1.5;-3.11;-5.21;-7.5;-7.46;-6.83;-7.13;-7.29;-7.01;-6.96;-7.67;-8.45;-8.82;-8.46;-7;-5.33;-4.5;-4.05;-3.49;-3;-2.46;-1.97;-1.97;-1.99;-1.62;-1.2;-1.55;-1.45;-0.15;1;0.72;0.15;
Segment 6;0;0.33;1.22;3.41;5.5;6.23;6.08;4.72;3.23;2.33;1.19;-0.2;-1.81;-3.14;-4.68;-6.77;-8.66;-10.01;-11.04;-12.24;-13.56;-15.27;-16.09;-15.4;-14.05;-12.52;-11.36;-10.42;-9.54;-8.76;-8.13;-7.92;-7.71;-7.19;-6.02;-4.11;-2.31;-1.04;-0.33;-0.04;0.14;
Segment 7;0;-1.19;-1.97;-2.71;-3.94;-5.18;-6.37;-7.16;-7.32;-7.03;-6.38;-5.26;-3.8;-2.23;-0.51;0.92;2.38;4.23;6;7.32;8.13;8.1;7.3;5.14;3.08;2.58;2.78;3.29;3.86;4.18;4.38;4.33;4.26;4.51;4.45;3.49;2.34;1.57;1.29;1.85;1.89;
Segment 8;0;-1.17;-2.18;-2.55;-2.79;-2.85;-3.27;-4.64;-6.1;-7.98;-9.98;-11.82;-13.57;-15.31;-16.78;-17.95;-18.35;-17.55;-15.79;-12.39;-9.12;-8.39;-7.98;-7.67;-7.51;-7.65;-7.95;-7.21;-6.16;-4.77;-3.7;-3.48;-3.67;-3.69;-3.68;-3.69;-3.23;-1.71;-0.15;0.53;0.5;
Segment 9;0;-0.95;-1.7;-1.08;-0.42;-1.04;-2.17;-3.25;-4.5;-6.14;-7.85;-9.26;-10.55;-11.74;-12.67;-13.29;-13.29;-12.69;-11.46;-9.26;-7.39;-7.53;-8.15;-8.7;-8.81;-8.29;-7.69;-6.49;-5.32;-4.55;-3.94;-3.53;-3.33;-3.21;-2.57;-1.24;0.16;0.68;0.56;0.33;0.1;
Segment 10;0;-0.86;-0.29;0.08;0.54;1.93;2.85;3.31;4.54;4.06;4.47;4.59;4.51;4.53;4.6;4.88;5.46;6.35;6.44;4.53;1.93;-0.71;-2.42;-3.02;-2.95;-3.6;-4.07;-4.24;-4.33;-4.4;-4.29;-3.9;-3.39;-3.27;-3.29;-3.79;-3.76;-0.83;2.42;3.95;3.44;
Segment 11;0;0.01;-0.02;0.15;0.25;0.11;-0.34;-0.76;-1;-1.16;-1.21;-1.13;-0.82;-0.14;0.82;1.3;1.47;1.29;0.71;-0.47;-1.82;-2.81;-3.49;-3.63;-3.55;-3.36;-3.12;-2.91;-2.73;-2.6;-2.49;-2.37;-2.3;-2.3;-2.42;-2.67;-2.52;-1.41;-0.34;-0.28;-0.42;
Segment 12;0;0.35;1.09;2.86;4.36;4.1;2.96;1.7;0.45;-0.38;-1.52;-3.66;-5.84;-7.43;-9;-11.03;-12.8;-13.78;-14.36;-15.04;-15.83;-16.84;-16.86;-15.21;-12.45;-9.64;-7.35;-6.31;-5.93;-5.54;-5.28;-5.13;-5.12;-5.27;-4.92;-3.5;-1.92;-0.96;-0.58;-0.59;-0.58;
Segment 13;0;0.46;0.87;1;0.59;-0.46;-1.87;-2.9;-4.35;-6.94;-10.14;-13.62;-16.63;-18.6;-19.85;-20.97;-21.84;-22.27;-22.52;-22.62;-22.43;-21.92;-20.62;-17.92;-14.94;-13.01;-11.69;-11.03;-10.59;-9.94;-9.16;-8.27;-7.41;-6.68;-5.63;-4.17;-2.64;-1.61;-0.97;-0.48;-0.22;
Segment 14;0;-1.29;-2.54;-3.82;-5.34;-7.39;-9.55;-11.18;-12.55;-13.66;-14.79;-15.86;-16.96;-18.23;-19.37;-20.22;-20.38;-19.98;-19.08;-18.72;-17.67;-14.88;-11.86;-9.39;-7.52;-6.53;-5.73;-4.66;-3.5;-2.47;-1.79;-1.48;-1.39;-1.33;-1.3;-1.35;-1.18;-0.58;0.11;0.63;0.51;
Segment 15;0;-1.8;-3.83;-5.49;-7.28;-9.51;-11.74;-13.25;-14.29;-15.09;-15.79;-16.74;-17.61;-18.25;-18.62;-18.55;-17.74;-15.92;-13.04;-9.25;-5.99;-4.9;-5.02;-5.07;-4.94;-4.09;-3.01;-2.3;-1.69;-1.22;-0.95;-0.84;-0.99;-1.4;-1.39;-0.24;1.12;1.72;1.71;1.17;0.5;
Segment 16;0;0.19;0.96;1.91;2.61;2.42;1.55;-0.13;-2.12;-4.16;-5.89;-6.8;-7.54;-8.81;-9.96;-10.53;-10.74;-10.65;-10.48;-10.21;-9.81;-9.32;-8.5;-7.38;-6.12;-5.17;-4.32;-3.5;-2.86;-2.49;-2.27;-2;-1.77;-1.61;-1.22;-0.38;0.59;1.29;1.7;1.95;1.5;
Segment 17;0;0.13;0.13;-0.16;-1.15;-2.95;-5.35;-8.24;-10.66;-11.87;-12.82;-14.4;-15.9;-16.82;-17.21;-17.06;-16.58;-15.88;-15.34;-15.35;-15.15;-14.3;-13.33;-12.81;-12.32;-11.31;-9.99;-8.81;-7.9;-7.38;-7.01;-6.45;-5.86;-5.31;-4.85;-4.5;-3.9;-2.66;-1.48;-1.03;-0.87;
Segment 18;0;-0.84;-1.92;-2.89;-4.05;-5.6;-7.11;-8.2;-9.12;-10.32;-11.6;-12.74;-13.51;-13.65;-13.67;-14.04;-13.94;-12.73;-11.09;-10.12;-9.49;-8.99;-8.42;-7.33;-5.67;-3.79;-2.19;-1.52;-1.38;-1.17;-1.1;-1.26;-1.58;-1.94;-1.87;-0.81;0.44;1.05;1.11;0.72;0.23;

思考

undefined

思考

Refining phase detection logic

I need to refine the phase detection logic. The idea is to start from 5-80% of the total length, but use a different approach if ES can go down to 0.4. I’m looking for the minimum in the range and calculating the phase between the start and the idx. I’ll also check if the Total minima stays above -3. Let me fine-tune that.

Thought for 2m 28s

回答

你的情况不建议继续用“第 0 帧 = ED、txt 里的 ES 毫秒数直接换算帧号”这条路线。更稳的方案是:

txt 只提供相位,不提供绝对时间。
比如你给的 txt 中 Total 最低谷在 319.54 ms,整段周期约 798.86 ms,所以 ES 相位约为 319.54 / 798.86 = 0.40。后续不再用“319.54 ms 对应多少帧”,而是用:

text
ES帧 = 选中的ED帧 + 0.40 × 当前视频自身估计出来的RR帧长

这样即使 AVI 转换后播放速度变了,只要帧序列没乱,仍然能按心动周期相位找 ES。

同时,ED 不建议固定第 0 帧,而是先在视频内估计多个候选 R 峰或周期起点,再通过图像清晰度、探头稳定度、边界帧安全性来自动选择质量最好的一轮心动周期。A3C 不能直接用 EchoNet-Dynamic 的 LV 面积最大/最小法,这个相位法反而更通用。

目前公开资源上,EchoNet-Dynamic 明确是 A4C 为主,提供 EF、EDV、ESV 和 LV tracing;CAMUS 公开数据是 A2C/A4C;EchoNet-LVH 主要是 PLAX,不是 A3C。A3C/心尖长轴相关自动轮廓有论文工作,但我没有看到像 EchoNet-Dynamic 或 CAMUS 这样成熟、通用、开箱即用且覆盖 A3C 的公开训练权重。(AI in Medicine & Imaging)

下面是按你现有脚本风格改的版本。它的核心变化是:Results.txt 只取 ES 相位;视频本身估计 RR 帧长;ED 在多个候选周期中选质量最好的一轮;同时输出 QC 表,方便人工复核。

python
# -*- coding: utf-8 -*- """ dump_esed_frames_robust.py 目标: 1. 不再假设第 0 帧一定是可用 ED; 2. 不再把 Results.txt 里的毫秒数直接换算成帧号; 3. Results.txt 只用于提取 ES 在一个心动周期中的相位; 4. 视频自身估计 RR 帧长; 5. 在多个候选 ED 周期中,根据图像质量与探头稳定性自动选择最佳周期; 6. 输出 ED/ES 关键帧和 QC 表,供医生绘制掩膜前复核。 依赖: pip install opencv-python numpy pandas """ import math import argparse from pathlib import Path import cv2 import numpy as np import pandas as pd # ========================= # 基础工具 # ========================= def get_video_meta(video_path: Path): cap = cv2.VideoCapture(str(video_path)) fps = cap.get(cv2.CAP_PROP_FPS) if fps is None or fps <= 1 or fps > 300 or math.isnan(fps): fps = 50.0 n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) cap.release() return float(fps), int(n), int(w), int(h) def read_all_frames(video_path: Path): cap = cv2.VideoCapture(str(video_path)) frames = [] if not cap.isOpened(): return frames, 50.0 fps = cap.get(cv2.CAP_PROP_FPS) if fps is None or fps <= 1 or fps > 300 or math.isnan(fps): fps = 50.0 while True: ok, frame = cap.read() if not ok: break frames.append(frame) cap.release() return frames, float(fps) def imwrite_safe(path: Path, image): """ Windows 下 cv2.imwrite 对中文路径有时不稳,这里用 imencode + tofile。 """ path.parent.mkdir(parents=True, exist_ok=True) ext = path.suffix.lower() if ext not in [".png", ".jpg", ".jpeg", ".bmp"]: ext = ".png" ok, buf = cv2.imencode(ext, image) if ok: buf.tofile(str(path)) return True return cv2.imwrite(str(path), image) def smooth_1d(x, window=5): x = np.asarray(x, dtype=np.float32) if len(x) < window or window <= 1: return x pad = window // 2 xp = np.pad(x, (pad, pad), mode="edge") kernel = np.ones(window, dtype=np.float32) / float(window) return np.convolve(xp, kernel, mode="valid") def fill_nan_1d(x): x = np.asarray(x, dtype=np.float32) idx = np.arange(len(x)) good = np.isfinite(x) if good.sum() == 0: return x if good.sum() == len(x): return x return np.interp(idx, idx[good], x[good]).astype(np.float32) def parse_float_list_from_semicolon_line(line, skip_first=True): parts = line.strip().split(";") if skip_first: parts = parts[1:] values = [] for p in parts: p = p.strip().replace(",", ".") if p == "": continue try: values.append(float(p)) except Exception: values.append(np.nan) return values # ========================= # Results.txt:只取 ES 相位,不直接取毫秒帧号 # ========================= def parse_strain_txt_for_es_phase(folder_path: Path): """ 从患者目录下的 Results.txt 中提取: ES_phase = Total longitudinal strain 最低谷所在时间 / 当前 strain 曲线总时长 注意: 1. 不使用 t_es_ms 直接换算帧号; 2. 因为 txt 不一定和视频一一对应,所以这里只把它当作患者级 ES 相位先验; 3. 多个 txt 时取中位数,增强鲁棒性。 """ txt_files = list(folder_path.glob("*Results*.txt")) if not txt_files: txt_files = list(folder_path.glob("*.txt")) phase_candidates = [] for txt_p in txt_files: try: with open(txt_p, "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() in_strain_section = False time_vals = None total_vals = None for raw in lines: line = raw.strip() if "Longitudinal Strain" in line: in_strain_section = True continue if not in_strain_section: continue if line.startswith("Time") and ";" in line: time_vals = parse_float_list_from_semicolon_line(line, skip_first=True) elif line.startswith("Total;"): total_vals = parse_float_list_from_semicolon_line(line, skip_first=True) break if time_vals is None or total_vals is None: continue L = min(len(time_vals), len(total_vals)) if L < 10: continue t = np.asarray(time_vals[:L], dtype=np.float32) y = fill_nan_1d(np.asarray(total_vals[:L], dtype=np.float32)) if not np.isfinite(t).all() or not np.isfinite(y).all(): continue rr_ms = float(t[-1] - t[0]) if rr_ms <= 200: continue y_s = smooth_1d(y, window=5) # 避免开头/末尾噪声抢最低谷,只在 5%~85% 区间内找 Total 最低谷 left = max(1, int(0.05 * L)) right = min(L, int(0.85 * L)) if right <= left + 2: left, right = 0, L local_idx = int(np.argmin(y_s[left:right])) min_idx = left + local_idx t_es_ms = float(t[min_idx] - t[0]) es_phase = t_es_ms / rr_ms # 正常静息状态下 ES 通常在心动周期前半段; # 范围放宽一些,避免低 EF 或传导延迟病例被误删。 if 0.20 <= es_phase <= 0.65: phase_candidates.append({ "phase": float(es_phase), "txt_name": txt_p.name, "t_es_ms": float(t[min_idx]), "rr_ms": float(rr_ms), "min_total_strain": float(y[min_idx]) }) except Exception: continue if not phase_candidates: return None, None phases = [x["phase"] for x in phase_candidates] phase_median = float(np.median(phases)) txt_summary = "|".join([ f"{x['txt_name']}:phase={x['phase']:.3f},t_es={x['t_es_ms']:.1f}ms" for x in phase_candidates ]) return phase_median, txt_summary # ========================= # 视频周期估计:用帧序列自身估计 RR 帧长 # ========================= def crop_bmode_gray(frame): """ 裁掉底部 ECG/文字区域,保留主要超声扇形图像区域。 如果你的设备 ECG 在顶部或右侧,需要微调这里的比例。 """ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) h, w = gray.shape y1 = int(0.03 * h) y2 = int(0.82 * h) x1 = int(0.04 * w) x2 = int(0.96 * w) return gray[y1:y2, x1:x2] def make_small_roi(frame, size=128): roi = crop_bmode_gray(frame) roi = cv2.resize(roi, (size, size), interpolation=cv2.INTER_AREA) roi = cv2.GaussianBlur(roi, (5, 5), 0) roi = cv2.normalize(roi, None, 0, 1, cv2.NORM_MINMAX) return roi.astype(np.float32) def compute_motion_signal(frames): """ 计算相邻帧差异,用于估计心动周期。 不依赖 LV 分割,因此 A2C/A3C/A4C 都能跑。 """ if len(frames) < 3: return np.zeros(len(frames), dtype=np.float32) rois = [make_small_roi(f, size=128) for f in frames] motion = [0.0] for i in range(1, len(rois)): diff = np.mean(np.abs(rois[i] - rois[i - 1])) motion.append(float(diff)) motion = np.asarray(motion, dtype=np.float32) motion = smooth_1d(motion, window=5) return motion def estimate_rr_frames_by_autocorr(frames, fps): """ 用视频运动信号自相关估计 RR 帧长。 如果视频只有一个心动周期,无法自相关,就退回 n-1。 """ n = len(frames) if n < 10: return max(1, n - 1), "too_short" motion = compute_motion_signal(frames) x = motion - float(np.mean(motion)) std = float(np.std(x)) if std < 1e-6: # 几乎没运动,退回常规估计 fallback = int(round(0.80 * fps)) fallback = int(np.clip(fallback, 10, max(10, n - 1))) return fallback, "flat_motion_fallback" min_lag = max(12, int(round(0.40 * fps))) # 150 bpm 左右 max_lag = min(int(round(1.60 * fps)), n // 2) # 37.5 bpm 左右 best_lag = None best_corr = -1.0 if max_lag > min_lag + 3: for lag in range(min_lag, max_lag + 1): a = x[:-lag] b = x[lag:] denom = float(np.linalg.norm(a) * np.linalg.norm(b)) if denom <= 1e-8: continue corr = float(np.dot(a, b) / denom) if corr > best_corr: best_corr = corr best_lag = lag if best_lag is not None and best_corr >= 0.10: return int(best_lag), f"motion_autocorr_corr={best_corr:.3f}" # 如果视频长度接近一个心动周期,直接把全长当作 RR。 if 20 <= n <= int(round(1.8 * fps)): return int(n - 1), "single_cycle_length" fallback = int(round(0.80 * fps)) fallback = int(np.clip(fallback, 10, max(10, n - 1))) return fallback, "default_0p8s" # ========================= # ECG 弱检测:只作为候选 ED,不作为唯一依据 # ========================= def ecg_height_signal_from_frames(frames): """ 尝试从视频底部 ECG 区域提取每帧 ECG 的相对高度信号。 该函数不保证所有设备都有效,所以只作为弱候选。 """ sig = [] for frame in frames: h, w = frame.shape[:2] # 默认 ECG 在底部 30% 区域 y1 = int(0.68 * h) y2 = int(0.98 * h) x1 = int(0.05 * w) x2 = int(0.95 * w) crop = frame[y1:y2, x1:x2] if crop.size == 0: sig.append(np.nan) continue hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) s = hsv[:, :, 1] v = hsv[:, :, 2] # ECG 线常见为绿色/黄色/白色高亮,阈值故意宽松 mask = ((s > 45) & (v > 80)) | (v > 220) ys = np.where(mask)[0] if len(ys) < 20: sig.append(np.nan) continue # R 波最高点对应 y 更小,这里取负号使 R 波变成峰值 y_top = float(np.percentile(ys, 5)) sig.append(-y_top) sig = np.asarray(sig, dtype=np.float32) sig = fill_nan_1d(sig) if len(sig) < 5 or not np.isfinite(sig).all(): return None sig_s = smooth_1d(sig, window=5) # 如果几乎不变,说明没提到有效 ECG 动态 if float(np.std(sig_s)) < 1.0: return None return sig_s def find_peaks_1d(x, min_distance): """ 简单局部峰检测,不依赖 scipy。 """ x = np.asarray(x, dtype=np.float32) if len(x) < 5: return [] threshold = float(np.percentile(x, 75)) raw_peaks = [] for i in range(1, len(x) - 1): if x[i] >= threshold and x[i] >= x[i - 1] and x[i] >= x[i + 1]: raw_peaks.append((i, float(x[i]))) if not raw_peaks: return [] # 先按峰高排序,再做距离抑制 raw_peaks = sorted(raw_peaks, key=lambda z: z[1], reverse=True) selected = [] for idx, amp in raw_peaks: if all(abs(idx - j) >= min_distance for j in selected): selected.append(idx) selected = sorted(selected) return selected def detect_ecg_r_peaks(frames, fps, rr_frames): sig = ecg_height_signal_from_frames(frames) if sig is None: return [], "ecg_not_detected" min_distance = max(8, int(round(0.45 * rr_frames))) peaks = find_peaks_1d(sig, min_distance=min_distance) # 如果只找到一堆很奇怪的峰,宁可不用 if len(peaks) == 0: return [], "ecg_no_peak" return peaks, f"ecg_peaks={peaks}" # ========================= # 质量评分:自动避开探头未稳定周期 # ========================= def frame_quality_score(frame): """ 单帧图像质量粗评分: - 清晰度:Laplacian 方差 - 对比度:95%-5% 灰度差 - 非黑区域比例 - 过曝比例惩罚 该分数不是医学质量评分,只是用于在同一视频内选择更稳的一轮周期。 """ roi = crop_bmode_gray(frame) lap_var = float(cv2.Laplacian(roi, cv2.CV_64F).var()) contrast = float(np.percentile(roi, 95) - np.percentile(roi, 5)) nonblack = float(np.mean(roi > 10)) saturated = float(np.mean(roi > 245)) score = np.log1p(lap_var) + 0.03 * contrast + 2.0 * nonblack - 5.0 * saturated return float(score) def phase_shift_between_frames(frame_a, frame_b): """ 用 phase correlation 粗估探头/画面整体位移。 位移过大通常提示探头不稳或切面漂移。 """ a = make_small_roi(frame_a, size=128) b = make_small_roi(frame_b, size=128) try: shift, response = cv2.phaseCorrelate(a, b) dx, dy = shift mag = math.sqrt(dx * dx + dy * dy) if response < 0.01: mag += 5.0 return float(mag) except Exception: return 0.0 def local_probe_jitter(frames, idx, radius=2): n = len(frames) shifts = [] left = max(0, idx - radius) right = min(n - 1, idx + radius) for i in range(left, right): shifts.append(phase_shift_between_frames(frames[i], frames[i + 1])) if not shifts: return 0.0 return float(np.median(shifts)) def build_ed_candidates(n, rr_frames, es_phase, ecg_peaks): """ 生成候选 ED: 1. ECG R 峰; 2. 从 0 开始按 RR 间隔外推的周期起点; 3. 每个候选点附近 +/-2 帧微调。 """ base = set() for p in ecg_peaks: base.add(int(p)) if rr_frames > 0: k = 0 while k * rr_frames < n: base.add(int(k * rr_frames)) k += 1 if not base: base.add(0) candidates = set() for c in base: for shift in [-2, -1, 0, 1, 2]: cc = int(c + shift) es = int(round(cc + es_phase * rr_frames)) if 0 <= cc < n and 0 <= es < n: candidates.add(cc) return sorted(candidates) def choose_best_esed(frames, fps, rr_frames, es_phase, ecg_peaks): n = len(frames) candidates = build_ed_candidates( n=n, rr_frames=rr_frames, es_phase=es_phase, ecg_peaks=ecg_peaks ) best = None for ed in candidates: es = int(round(ed + es_phase * rr_frames)) if es < 0 or es >= n: continue ed_q = frame_quality_score(frames[ed]) es_q = frame_quality_score(frames[es]) ed_jitter = local_probe_jitter(frames, ed, radius=2) es_jitter = local_probe_jitter(frames, es, radius=2) # 边界安全:不要选太靠近结尾的周期 margin = min(ed, n - 1 - es) / max(1, n - 1) # 如果视频明显有多周期,轻微惩罚第 0 帧附近的周期,避免探头刚落下不稳 first_cycle_penalty = 0.0 if n >= int(round(1.4 * rr_frames)) and ed <= 2: first_cycle_penalty = 2.0 score = ( ed_q + es_q - 0.80 * ed_jitter - 0.80 * es_jitter + 0.50 * margin - first_cycle_penalty ) item = { "ed": int(ed), "es": int(es), "score": float(score), "ed_q": float(ed_q), "es_q": float(es_q), "ed_jitter": float(ed_jitter), "es_jitter": float(es_jitter), "margin": float(margin), "first_cycle_penalty": float(first_cycle_penalty) } if best is None or item["score"] > best["score"]: best = item if best is None: # 最坏兜底 ed = 0 es = int(round(es_phase * max(1, n - 1))) es = int(np.clip(es, 0, n - 1)) best = { "ed": int(ed), "es": int(es), "score": -999.0, "ed_q": np.nan, "es_q": np.nan, "ed_jitter": np.nan, "es_jitter": np.nan, "margin": np.nan, "first_cycle_penalty": np.nan } return best # ========================= # 可选:全帧导出 # ========================= def dump_all_frames_with_marks(frames, out_dir: Path, ed_idx: int, es_idx: int): out_dir.mkdir(parents=True, exist_ok=True) for i, frame in enumerate(frames): suffix = "" if i == ed_idx: suffix = "_ED" elif i == es_idx: suffix = "_ES" img_name = f"frame_{i:04d}{suffix}.png" imwrite_safe(out_dir / img_name, frame) # ========================= # 主流程 # ========================= def main(): parser = argparse.ArgumentParser() parser.add_argument( "--success_log", type=str, default=r"D:\echo_autolabel\success_log.csv" ) parser.add_argument( "--accepted_root", type=str, default=r"D:\echo_project\data\pseudo_labeled_v6_final\Accepted" ) parser.add_argument( "--summary_out", type=str, default=r"D:\echo_project\data\esed_keyframes_summary" ) parser.add_argument( "--full_dump_out", type=str, default=r"D:\echo_project\data\esed_full_frame_dump" ) parser.add_argument( "--max_per_view", type=int, default=30 ) parser.add_argument( "--dump_all", action="store_true", help="是否额外导出每个视频的全量帧。默认只导出 ED/ES。" ) parser.add_argument( "--default_es_phase", type=float, default=0.40, help="txt 缺失或解析失败时的默认 ES 相位。" ) args = parser.parse_args() success_log = Path(args.success_log) accepted_root = Path(args.accepted_root) summary_out = Path(args.summary_out) full_dump_out = Path(args.full_dump_out) summary_out.mkdir(parents=True, exist_ok=True) print("\n========================================================================") print("ESED关键帧提取:相位驱动 + 周期质量优选模式") print(f"summary_out: {summary_out}") print("========================================================================\n") if not success_log.exists(): print(f"运行中止:找不到 success_log.csv: {success_log}") return df_log = pd.read_csv(success_log) df_log["New_Filename"] = df_log["New_Filename"].astype(str).str.strip() df_log["Original_File_Path"] = df_log["Original_File_Path"].astype(str).str.strip() mapping = dict(zip(df_log["New_Filename"], df_log["Original_File_Path"])) qc_rows = [] total_processed = 0 for view in ["A2C", "A3C", "A4C"]: view_dir = accepted_root / view if not view_dir.exists(): print(f"未检测到切面文件夹,跳过: {view_dir}") continue avi_files = sorted(list(view_dir.glob("*.avi"))) print(f"\n正在处理切面 {view},候选视频数: {len(avi_files)}") view_count = 0 for v_path in avi_files: if view_count >= args.max_per_view: print(f"{view} 已达到 max_per_view={args.max_per_view},停止该切面。") break new_name = v_path.name orig_path_str = mapping.get(new_name, "") orig_path = Path(orig_path_str) if orig_path_str else None # 优先处理原始路径;如果原始路径不可用,则退回 accepted avi。 if orig_path is not None and orig_path.exists(): target_video = orig_path patient_folder = orig_path.parent else: target_video = v_path patient_folder = v_path.parent print(f"[{view} {view_count + 1}/{args.max_per_view}] {target_video.name}") frames, fps = read_all_frames(target_video) if len(frames) < 5: print(f" 跳过:视频无法读取或帧数过少: {target_video}") continue # 1. 从 txt 提取 ES 相位 es_phase, txt_summary = parse_strain_txt_for_es_phase(patient_folder) if es_phase is None: es_phase = float(args.default_es_phase) txt_summary = "no_valid_txt_use_default" # 2. 从视频自身估计 RR 帧长 rr_frames, rr_method = estimate_rr_frames_by_autocorr(frames, fps) # 3. ECG 弱检测,生成候选 ED ecg_peaks, ecg_method = detect_ecg_r_peaks(frames, fps, rr_frames) # 4. 多候选周期质量评分,选择 ED/ES best = choose_best_esed( frames=frames, fps=fps, rr_frames=rr_frames, es_phase=es_phase, ecg_peaks=ecg_peaks ) ed_idx = int(best["ed"]) es_idx = int(best["es"]) # 5. 导出 ED/ES 汇总图 stem = target_video.stem ed_name = f"{stem}_{view}_ED_f{ed_idx:04d}.png" es_name = f"{stem}_{view}_ES_f{es_idx:04d}.png" imwrite_safe(summary_out / ed_name, frames[ed_idx]) imwrite_safe(summary_out / es_name, frames[es_idx]) # 6. 可选导出全帧 if args.dump_all: per_video_dir = full_dump_out / view / stem dump_all_frames_with_marks( frames=frames, out_dir=per_video_dir, ed_idx=ed_idx, es_idx=es_idx ) qc_rows.append({ "view": view, "new_filename": new_name, "target_video": str(target_video), "patient_folder": str(patient_folder), "fps_read_by_cv2": fps, "n_frames": len(frames), "rr_frames": rr_frames, "rr_method": rr_method, "es_phase": es_phase, "ed_frame": ed_idx, "es_frame": es_idx, "ecg_method": ecg_method, "ecg_peaks": ",".join(map(str, ecg_peaks)), "txt_summary": txt_summary, "cycle_score": best["score"], "ed_quality": best["ed_q"], "es_quality": best["es_q"], "ed_jitter": best["ed_jitter"], "es_jitter": best["es_jitter"], "first_cycle_penalty": best["first_cycle_penalty"], "ed_png": str(summary_out / ed_name), "es_png": str(summary_out / es_name) }) view_count += 1 total_processed += 1 qc_df = pd.DataFrame(qc_rows) qc_csv = summary_out / "esed_keyframe_qc.csv" qc_df.to_csv(qc_csv, index=False, encoding="utf-8-sig") print("\n========================================================================") print(f"处理完成:{total_processed} 个视频") print(f"关键帧汇总目录: {summary_out}") print(f"QC表: {qc_csv}") print("建议先人工复核 QC 表中 cycle_score 低、ed_jitter/es_jitter 高的视频。") print("========================================================================") if __name__ == "__main__": main()

运行方式:

bash
python dump_esed_frames_robust.py

只导出 ED/ES 汇总图。如果你还想像原脚本一样导出每个视频的全量帧:

bash
python dump_esed_frames_robust.py --dump_all

建议你后续人工复核时优先看 esed_keyframe_qc.csv 里这几列:cycle_scoreed_jitteres_jitterrr_methodes_phasejitter 高的视频多半存在探头移动或切面漂移,医生画掩膜前最好单独确认。


掩膜绘制工具安装与使用建议

我建议你先用 Label Studio,因为你当前任务是给医生画 ED/ES 两帧的 LV blood pool 或心肌轮廓,Label Studio 上手最快,支持图像语义分割和 brush/polygon 标注;官方文档也明确支持图像标注项目和 Brush 类分割标签。(Label Studio)

方案一:Label Studio,适合快速启动

安装:

bash
conda create -n echo_mask python=3.10 -y conda activate echo_mask pip install label-studio label-studio start

浏览器打开:

text
http://localhost:8080

使用流程:

  1. 新建 Project。
  2. 上传 esed_keyframes_summary 文件夹里的 PNG。
  3. 选择 Image Segmentation 模板。
  4. 标签建议设置为:
    • LV_BloodPool
    • 如果你后续要做心肌壁厚度,可再加 LV_Myocardium
  5. 医生用 Brush 或 Polygon 画 ED/ES 掩膜。
  6. 导出 JSON,然后你再转成 PNG mask 或 COCO 格式。

推荐的 Label Studio 标注配置可以用这个:

xml
<View> <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/> <BrushLabels name="label" toName="image"> <Label value="LV_BloodPool" background="#ff0000"/> <Label value="LV_Myocardium" background="#00ff00"/> </BrushLabels> </View>

如果医生更习惯点轮廓而不是涂抹,可以改用 Polygon:

xml
<View> <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/> <PolygonLabels name="label" toName="image" strokeWidth="3" pointSize="small"> <Label value="LV_BloodPool" background="#ff0000"/> <Label value="LV_Myocardium" background="#00ff00"/> </PolygonLabels> </View>

方案二:CVAT,适合多人协作和大规模项目

如果后面标注量变大,或者你要让多个医生分任务、做复核、做一致性检查,CVAT 更适合。CVAT 官方安装路线是 Docker Compose,本身面向图像、视频和分割标注任务。(CVAT Documentation)

安装:

bash
git clone https://github.com/cvat-ai/cvat cd cvat docker compose up -d

然后创建管理员账号:

bash
docker exec -it cvat_server bash -ic 'python3 ~/manage.py createsuperuser'

浏览器打开:

text
http://localhost:8080

使用流程:

  1. 新建 Task。
  2. 上传 ED/ES PNG。
  3. 创建标签 LV_BloodPoolLV_Myocardium
  4. 用 Polygon 或 Brush 工具画掩膜。
  5. 导出为 COCO、Segmentation Mask 或 CVAT XML。
  6. 后续可把导出的 mask 转成训练用的二值 PNG。

你的当前阶段我会建议这样排优先级:

第一阶段: 用上面的脚本稳定导出 ED/ES,并人工复核 50–100 例。
第二阶段: 用 Label Studio 让医生画 LV blood pool 掩膜。
第三阶段: 用这些自有 ED/ES 掩膜训练一个 A2C/A3C/A4C 联合 U-Net 或 nnU-Net。
第四阶段: 再反过来用模型自动分割全视频,提取你前面设计的局部节段收缩特征。

问题

我现在想要做一个让医生帮我质检并绘制掩膜的前端,当前代码的问题是,掩膜工作仓的个体状态分类错误,全都归类到质量极差中了,绘制好淹没的个体也没有进入已完成,且自动循环播放视频时,界面的其他操作无法进行,我原本想法是可以一遍看着视频一遍进行其他操作,自动播放和手动播放只是为了给医生更好的体验,但期间我尝试修改自动播放出现的bug是整个页面都在闪,没法进行操作,现在的代码至少还可以切换到手动模式然后进行操作,这个要是不好改就不改,现在至少页面是功能齐全且能够运行的,只是没达到预期。比较严重的问题是,现在掩膜工作仓的个体状态分类错误,全都归类到质量极差中了,绘制好淹没的个体也没有进入已完成,我需要能够读取文件夹内生成的掩膜josn文件更新网页中的进度,且需要找到分类错误的原因并解决此问题,我怀疑是写入和读取csv文件时对于00开头的文件直接删掉了0所以找不到对应,或者别的什么文本对应上的问题。在原代码基础上进行修改,如果拆分成多个脚本调用会使整个任务运行更顺畅,则拆分多个组件脚本实现最终目标,如果有更优的网页设计逻辑也可以尝试,只要我想要的功能都留下即可。
需要你代码框生成代码给我,不要生成文件,我下载不了。

success_log.csv内容形式前几行如下
Patient_ID New_Filename Original_Folder Original_File_Path File_Type Extracted_Views
1 0001_08.avi 20211011+LI WEI E:\datasets\20211011+LI WEI\08_IM_0099.avi Video N/A
1 0001_10.avi 20211011+LI WEI E:\datasets\20211011+LI WEI\10_IM_0100.avi Video N/A

当前脚本代码
步骤一:python D:\echo_project\echo_cad_workflow\find_ESED\extract_all_candidates.py把 90 例视频的隐藏周期全抠出来。运行结束D:\echo_project\data\frontend_candidates\ 目录下密密麻麻长出了所有带心电图的候选图片和一张账目大表。
步骤二:在终端中,直接输入以下命令开启网页引擎:
streamlit run E:\echo_project\echo_cad_workflow\find_ESED\review_dashboard.py
预期效果:输入后,弹出交互屏
作为医工交叉课题,数据安全是红线,Streamlit 的底层架构天然保证了这一点:
本地虚拟服务器(Localhost):Streamlit 的原理是在你自己的电脑内部临时搭建了一个“只对你自己可见”的虚拟局域网服务器(即 localhost 或 127.0.0.1)。在浏览器里看到的网页,其本质是你的 Python 解释器在本地把 D 盘的图片渲染成网页贴给你的浏览器看。外网的任何服务器、甚至 Streamlit 官方,都没有任何权限和通道能跨越公网来窥探你的本地硬盘。
如何查看结果:你可以直接用 Windows 资源管理器打开 D:\echo_project\data\final_mask_ready_library\。目前通过前三个患者导出的最干净、无心电图、无任何字符污染的纯净图片已经静静地躺在里面了。

review_dashboard.py

-- coding: utf-8 --

"""
review_dashboard.py (双工作舱独立分流 + 三向标注状态漏斗过滤 + 强类型死锁对账版)
"""

import os
import sys
import math
import importlib.util
from pathlib import Path
import time
import json
import subprocess # 用于唤醒外部独立的 LabelMe 软件
import cv2
import numpy as np
import pandas as pd
import streamlit as st

st.set_page_config(layout="wide", page_title="左心室黄金关键帧人机交互盲审系统")

⚙️ 核心路径锚点严格锁定为 E 盘移动硬盘

CANDIDATES_DIR = Path(r"E:\echo_project\data\frontend_candidates")
REGISTRY_CSV = CANDIDATES_DIR / "candidates_registry.csv"
FINAL_MASK_DIR = Path(r"E:\echo_project\data\final_mask_ready_library")
FINAL_MASK_DIR.mkdir(parents=True, exist_ok=True)

终极数据资产清单大表

MANIFEST_CSV = FINAL_MASK_DIR / "final_annotation_manifest.csv"

@st.cache_data
def load_registry():
if not REGISTRY_CSV.exists(): return None
return pd.read_csv(REGISTRY_CSV)

─── 🎯 飞梭旋钮核心引擎 ───

@st.cache_data
def load_video_frames(video_path_str):
try:
cap = cv2.VideoCapture(video_path_str)
frames = []
while True:
ret, frame = cap.read()
if not ret: break
frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
cap.release()
return frames
except Exception:
return []

def extract_clean_frame(video_path, orig_video_path, orig_target_frame, output_png_path):
"""根据原视频帧号与帧率自适应换算,提取 Accepted 纯净视频的最干净帧"""
cap_orig = cv2.VideoCapture(str(orig_video_path))
orig_fps = cap_orig.get(cv2.CAP_PROP_FPS) or 50.0
cap_orig.release()

text
cap_clean = cv2.VideoCapture(str(video_path)) clean_fps = cap_clean.get(cv2.CAP_PROP_FPS) or 50.0 clean_n = int(cap_clean.get(cv2.CAP_PROP_FRAME_COUNT)) # 跨时间轴绝对秒数对齐 time_sec = orig_target_frame / orig_fps clean_frame_idx = int(np.clip(round(time_sec * clean_fps), 0, clean_n - 1)) cap_clean.set(cv2.CAP_PROP_POS_FRAMES, clean_frame_idx) ok, frame = cap_clean.read() cap_clean.release() if ok: cv2.imencode('.png', frame)[1].tofile(str(output_png_path)) return True return False

def append_to_manifest(data_dict):
"""增量写入中央台账大表(自动覆盖旧历史记录)"""
if MANIFEST_CSV.exists():
df_old = pd.read_csv(MANIFEST_CSV)
if 'case_key' in df_old.columns:
df_old['case_key'] = df_old['case_key'].astype(str).str.strip()
df_old = df_old[df_old['case_key'] != str(data_dict['case_key']).strip()]
df_new = pd.concat([df_old, pd.DataFrame([data_dict])], ignore_index=True)
else:
df_new = pd.DataFrame([data_dict])
df_new.to_csv(MANIFEST_CSV, index=False, encoding="utf-8-sig")

─── 🔮 拓扑合成核心 ───

def _polygon_to_binary_bitmask(points, shape):
mask = np.zeros(shape[:2], dtype=np.uint8)
pts = np.array(points, dtype=np.int32)
cv2.fillPoly(mask, [pts], 1)
return mask

def auto_compile_labelme_json_to_camus_mask(json_path, out_mask_path):
"""异步感知并解析 LabelMe 的 json,原位合并压制为 0=背景, 1=腔, 2=心肌的图像"""
try:
with open(json_path, "r", encoding="utf-8") as f:
meta = json.load(f)
h = meta["imageHeight"]
w = meta["imageWidth"]

text
epi_mask = np.zeros((h, w), dtype=np.uint8) endo_mask = np.zeros((h, w), dtype=np.uint8) for shape in meta["shapes"]: label = shape["label"].strip().lower() points = shape["points"] if label == "lv_epi": epi_mask = np.maximum(epi_mask, _polygon_to_binary_bitmask(points, (h, w))) elif label == "lv_endo": endo_mask = np.maximum(endo_mask, _polygon_to_binary_bitmask(points, (h, w))) # 拓扑区域扣减公式:心肌壁 = 外膜包络圈 - 内膜腔体圈 myocardium_mask = ((epi_mask > 0) & (endo_mask == 0)).astype(np.uint8) camus_mask = np.zeros((h, w), dtype=np.uint8) camus_mask[endo_mask > 0] = 1 # 1 代表左心室腔血池 camus_mask[myocardium_mask > 0] = 2 # 2 代表纯肌肉壁 cv2.imwrite(str(out_mask_path), camus_mask) return True except Exception: return False

def main():
if not REGISTRY_CSV.exists():
st.error(f"❌ 运行中断:在移动硬盘上找不到中间台账表,请确认路径: {REGISTRY_CSV}")
return

text
# 读取主注册台账并强行死锁 case_key 为常规字符串防止滑轨 df = pd.read_csv(REGISTRY_CSV) df['case_key'] = df['case_key'].astype(str).str.strip() df['clean_video_path'] = df['clean_video_path'].str.replace('D:', 'E:', case=False) df['original_video_path'] = df['original_video_path'].str.replace('D:', 'E:', case=False) progress_file = CANDIDATES_DIR / "review_progress.txt" completed_keys = [] if progress_file.exists(): with open(progress_file, "r", encoding="utf-8") as f: completed_keys = [line.strip() for line in f.readlines() if line.strip()] all_cases = df['case_key'].unique() pending_cases = [c for c in all_cases if c not in completed_keys] # ─── 🛡️ 核心修复:安全加载资产台账并全量死锁为 str 统一对账线 ─── manifest_df = pd.DataFrame() if MANIFEST_CSV.exists(): try: manifest_df = pd.read_csv(MANIFEST_CSV) if 'case_key' in manifest_df.columns: manifest_df['case_key'] = manifest_df['case_key'].astype(str).str.strip() except Exception: pass drawn_cases = [] # 分池 1: 已绘制完成的个体 undrawn_cases = [] # 分池 2: 已过一阶段质检、且有ESED图等待LabelMe绘制的个体 unlabelable_cases = [] # 分池 3: 一阶段判定为低质量/模糊、触发熔断无法绘制的个体 for c_key in completed_keys: c_key_str = str(c_key).strip() # 深度数据对账校验 if not manifest_df.empty and c_key_str in manifest_df['case_key'].values: rec = manifest_df[manifest_df['case_key'] == c_key_str].iloc[0] v_status = str(rec.get('view_status', '')).strip().lower() m_status = str(rec.get('mask_status', '')).strip().lower() val_view = str(rec.get('validated_view', '')).strip() folder = str(rec.get('original_folder', '')).strip() stem = str(rec.get('original_stem', '')).strip() if m_status == 'unlabelable' or v_status == 'unidentifiable': unlabelable_cases.append(c_key_str) else: # 物理盘点:复核 E 盘上是否已经真实长出了对应的双分类真值掩膜 b_stem = f"{folder}_{stem}_{val_view}" mask_ed_p = FINAL_MASK_DIR / f"{b_stem}_ED_mask.png" mask_es_p = FINAL_MASK_DIR / f"{b_stem}_ES_mask.png" if mask_ed_p.exists() and mask_es_p.exists(): drawn_cases.append(c_key_str) # 联动同步:将完工标志刷入大表锁死 if int(rec.get('annotation_completed', 0)) != 1: manifest_df.loc[manifest_df['case_key'] == c_key_str, 'annotation_completed'] = 1 manifest_df.to_csv(MANIFEST_CSV, index=False, encoding="utf-8-sig") else: undrawn_cases.append(c_key_str) else: unlabelable_cases.append(c_key_str) # ─── 📻 侧边栏中央主工作舱分流枢纽 ─── st.sidebar.title("📊 核心质检与标注流水线") st.sidebar.metric("全库解算总样本", f"{len(all_cases)} 个") st.sidebar.metric("一阶段已审完入库", f"{len(completed_keys)} 个") workspace = st.sidebar.radio("📊 选择当前主工作舱", ["🌟 关键帧质检对账舱 (待评审)", "🎨 密集掩膜标注舱 (已入库历史)"]) is_history_mode = False if workspace == "🌟 关键帧质健对账舱 (待评审)": if not pending_cases: st.balloons() st.success("🎉 报告主帅!当前队列中所有的样本已 100% 完成关键帧质检落盘!") return current_case = st.sidebar.selectbox("🎯 选定当前待核对病例", pending_cases) else: is_history_mode = True sub_filter = st.sidebar.radio( "📋 筛选历史个体状态 (LabelMe 靶向选单)", [ f"🔴 待标注心内心外膜 ({len(undrawn_cases)}个)", f"🟢 已绘制完成/任务结束 ({len(drawn_cases)}个)", f"❌ 质量极差/模糊 (免绘熔断) ({len(unlabelable_cases)}个)" ] ) if "待标注" in sub_filter: target_pool = undrawn_cases elif "已绘制完成" in sub_filter: target_pool = drawn_cases else: target_pool = unlabelable_cases if not target_pool: st.sidebar.info("💡 当前筛选分类下暂无病例个体") current_case = st.sidebar.selectbox("↩️ 强行调阅全量历史总池", completed_keys if completed_keys else ["无"]) if current_case == "无": return else: current_case = st.sidebar.selectbox("🎯 选定待标注/核对历史目标", target_pool) current_case_str = str(current_case).strip() case_df = df[df['case_key'] == current_case_str].sort_values(by="cycle_number") first_row = case_df.iloc[0] # ─── 状态机记忆恢复机制 ─── v_idx, m_idx, score_val, fs_val, saved_view = 0, 0, 4, False, first_row['view'] if not manifest_df.empty and current_case_str in manifest_df['case_key'].values: hist_rec = manifest_df[manifest_df['case_key'] == current_case_str].iloc[0] v_status = str(hist_rec.get('view_status', 'accepted')) m_status = str(hist_rec.get('mask_status', 'labelable')) score_val = int(hist_rec.get('acoustic_window_score', 4)) fs_val = bool(hist_rec.get('is_foreshortening', 0)) saved_view = hist_rec.get('validated_view', first_row['view']) if v_status == "unidentifiable": v_idx = 2 elif v_status == "corrected": v_idx = 1 m_idx = 1 if m_status == "unlabelable" else 0 base_stem = f"{first_row['original_folder_name']}_{first_row['original_stem']}_{saved_view}" # ─── 🎨 侧边栏折叠面板:LabelMe 异步智能联动感知对账监控台 ─── with st.sidebar.expander("🎨 实时掩膜绘制工作台 (LV Mask Studio)", expanded=True): st.markdown("### ✏ 2D 心内膜血池多边形套索") st.caption("完成关键帧卡段或在左侧切换至 [待标注] 历史列表后,点击下方按钮一键秒级精确空降唤醒原厂 LabelMe 软件:") json_ed_path = FINAL_MASK_DIR / f"{base_stem}_ED.json" json_es_path = FINAL_MASK_DIR / f"{base_stem}_ES.json" target_ed_mask_path = FINAL_MASK_DIR / f"{base_stem}_ED_mask.png" target_es_mask_path = FINAL_MASK_DIR / f"{base_stem}_ES_mask.png" target_ed_img_path = FINAL_MASK_DIR / f"{base_stem}_ED.png" target_es_img_path = FINAL_MASK_DIR / f"{base_stem}_ES.png" # 🚀 异步验证压制核心 if json_ed_path.exists(): auto_compile_labelme_json_to_camus_mask(json_ed_path, target_ed_mask_path) if json_es_path.exists(): auto_compile_labelme_json_to_camus_mask(json_es_path, target_es_mask_path) if not manifest_df.empty and current_case_str in manifest_df['case_key'].values: if target_ed_mask_path.exists() and target_es_mask_path.exists(): if int(manifest_df.loc[manifest_df['case_key'] == current_case_str, 'annotation_completed'].values[0]) != 1: manifest_df.loc[manifest_df['case_key'] == current_case_str, 'annotation_completed'] = 1 manifest_df.to_csv(MANIFEST_CSV, index=False, encoding="utf-8-sig") # ED 状态动态感知 if target_ed_img_path.exists(): status_ed = "🟢 已绘制完成 (CAMUS Ready)" if target_ed_mask_path.exists() else "🔴 未绘制 (待 LabelMe)" st.markdown(f"**1. 舒张末期 (ED) 状态**: {status_ed}") if st.button("✏️ 启动 LabelMe 绘制 ED 掩膜", key=f"draw_ed_{current_case_str}", use_container_width=True): st.toast("🚀 正在为您秒级唤醒原厂 LabelMe 独立精修大屏...") subprocess.Popen(["labelme", str(FINAL_MASK_DIR), "--labels", "__ignore__,_background_,lv_endo,lv_epi", f"--nodata"]) else: st.caption("🔒 ED 纯净帧未生成(请先在下级对账舱提交关键帧)") st.markdown("<br>", unsafe_allow_html=True) # ES 状态动态感知 if target_es_img_path.exists(): status_es = "🟢 已绘制完成 (CAMUS Ready)" if target_es_mask_path.exists() else "🔴 未绘制 (待 LabelMe)" st.markdown(f"**2. 收缩末期 (ES) 状态**: {status_es}") if st.button("✏️ 启动 LabelMe 绘制 ES 掩膜", key=f"draw_es_{current_case_str}", use_container_width=True): st.toast("🚀 正在为您秒级唤醒原厂 LabelMe 独立精修大屏...") subprocess.Popen(["labelme", str(FINAL_MASK_DIR), "--labels", "__ignore__,_background_,lv_endo,lv_epi", f"--nodata"]) else: st.caption("🔒 ES 纯净帧未生成(请先在下级对账舱提交关键帧)") if target_ed_mask_path.exists() and target_es_mask_path.exists(): st.markdown("---") st.success("✨ 标记:该个体的双层掩膜标注任务已 100% 全量完工!") # ─── 布局:开辟左右黄金分治双大屏 ─── col_left_panel, col_right_canvas = st.columns([2, 3]) with col_left_panel: st.markdown("### 🖥️ 动态超声观测与决策中心") orig_video_p = Path(first_row['original_video_path']) if orig_video_p.exists(): video_frames = load_video_frames(str(orig_video_p)) if video_frames: total_f = len(video_frames) st.markdown(f"**🎞️ 原始影像控制台 (含门控心电图)** | 归属: `{first_row['original_folder_name']}`") play_mode = st.radio( "▶️ 视频播放控制模式:", ["🔄 自动循环播放", "🎛️ PACS 手动飞梭"], key=f"pmode_{current_case_str}", horizontal=True ) if play_mode == "🔄 自动循环播放": img_placeholder = st.empty() frame_idx = 0 while True: img_placeholder.image( video_frames[frame_idx], caption=f"📺 原始影像自动循环中:第 {frame_idx} 帧 / 共 {total_f} 帧", use_container_width=True ) time.sleep(0.04) frame_idx = (frame_idx + 1) % total_f else: scrub_idx = st.slider( "👉 **鼠标拖动或利用键盘 [←] [→] 方向键精准抠帧:** ", 0, total_f - 1, 0, key=f"scrub_{current_case_str}" ) st.image(video_frames[scrub_idx], caption=f"📺 原始影像当前帧静态预览:第 {scrub_idx} 帧", use_container_width=True) else: st.error("⚠️ 错误:该原始原始 AVI 文件在本地无法解析帧序列。") else: st.warning(f"⚠️ 提示:未检测到原始带心电图视频,请核对路径: {orig_video_p}") local_dump_folder = Path(r"E:\echo_project\data\latency_calibration") / orig_video_p.stem st.markdown("**📁 本地全量原始帧拆解目录 (含心电图)**") st.code(str(local_dump_folder), language="text") st.markdown("---") if "prev_case" not in st.session_state or st.session_state.prev_case != current_case_str: st.session_state.prev_case = current_case_str st.session_state.selected_ed_node = None st.session_state.selected_es_node = None st.markdown("### ⌨️ 双轨寻帧模式切换") manual_mode = st.checkbox("🎯 **开启纯手工精确键入帧号模式**") if manual_mode: st.caption("📝 请输入原视频(带心电图)对应的完美图像帧号:") m_col1, m_col2 = st.columns(2) with m_col1: man_ed = st.number_input("手动指定 ED 帧号", min_value=0, max_value=500, value=0, key=f"med_{current_case_str}") st.session_state.selected_ed_node = { "id": "manual", "frame_idx": man_ed, "cycle": "Manual", "desc": f"专家手工指定 (原视频第 {man_ed} 帧)", "clean_path": first_row['clean_video_path'], "orig_path": first_row['original_video_path'] } with m_col2: man_es = st.number_input("手动指定 ES 帧号", min_value=0, max_value=500, value=20, key=f"mes_{current_case_str}") st.session_state.selected_es_node = { "id": "manual", "frame_idx": man_es, "cycle": "Manual", "desc": f"专家手工指定 (原视频第 {man_es} 帧)", "clean_path": first_row['clean_video_path'], "orig_path": first_row['original_video_path'] } st.markdown("**🎯 黄金关键帧当前捕获钢印**") if st.session_state.selected_ed_node: st.error(f"🔴 **ED (舒张起点)**:{st.session_state.selected_ed_node['desc']}") else: st.markdown("⚪ **ED 状态**:⏱️ 待捕获") if st.session_state.selected_es_node: st.success(f"🟢 **ES (收缩终点)**:{st.session_state.selected_es_node['desc']}") else: st.markdown("⚪ **ES 状态**:⏱️ 待捕获") if not manual_mode and (st.session_state.selected_ed_node or st.session_state.selected_es_node): if st.button("🔄 清空当前点击状态", use_container_width=True): st.session_state.selected_ed_node = None st.session_state.selected_es_node = None st.rerun() st.markdown("---") st.markdown("### 📐 临床切面与质量评估控制台") view_check = st.radio( "📐 **1. 切面分类核对结果 (当前预判: " + first_row['view'] + ")**", ["正确", "错误 (需下方修正)", "模糊/非标切面 (彻底无法分类)"], index=v_idx, key=f"vcheck_{current_case_str}" ) corrected_view = saved_view if "错误" in view_check: default_corrected_idx = 0 if is_history_mode and saved_view in ["A2C", "A3C", "A4C"]: default_corrected_idx = ["A2C", "A3C", "A4C"].index(saved_view) corrected_view = st.selectbox( "🔁 请选择该病例实际对应的正确切面标签:", ["A2C", "A3C", "A4C"], index=default_corrected_idx, key=f"corr_view_select_{current_case_str}" ) elif "模糊" in view_check: corrected_view = "Unidentifiable" st.session_state.corrected_view_state = corrected_view mask_check = st.radio( "🎨 **2. 掩膜标注可行性评估**", ["质量合格 (可绘制心内膜掩膜)", "质量极差 (声窗盲区/拒绝标注掩膜)"], index=m_idx, key=f"mcheck_{current_case_str}" ) qc_score = st.slider("🌟 **3. 声窗图像质量评分 (Acoustic Window QC)**", min_value=1, max_value=5, value=score_val, step=1, key=f"qcs_{current_case_str}") foreshortening_check = st.checkbox("⚠️ 存在心尖随动缩短 (Foreshortening)", value=fs_val, key=f"fs_{current_case_str}") st.markdown("---") is_view_unidentifiable = "模糊" in view_check is_mask_unlabelable = "质量极差" in mask_check should_extract = (not is_view_unidentifiable) and (not is_mask_unlabelable) if should_extract: if st.session_state.selected_ed_node and st.session_state.selected_es_node: st.success(f"🎯 **校验通过**: 保存目标切面标签为 **[{corrected_view}]**") can_submit = True elif is_history_mode: st.info(f"ℹ️ 历史调阅中:当前正在复核 [{corrected_view}] 标签资产") can_submit = True else: st.error("⏳ **锁闭警告**: 必须完成两帧号的捕获后方可解锁提交!") can_submit = False else: st.warning("⚠️ **熔断模式**: 当前案例触发质量问题,系统将仅保留台账。") can_submit = True if st.button("🔥 提交质检结论并放行下一例", type="primary", use_container_width=True, disabled=not can_submit): if "模糊" in view_check: view_status_str = "unidentifiable" elif "错误" in view_check: view_status_str = "corrected" else: view_status_str = "accepted" mask_status_str = "unlabelable" if is_mask_unlabelable else "labelable" fresh_base_stem = f"{first_row['original_folder_name']}_{first_row['original_stem']}_{corrected_view}" new_ed_png = f"{fresh_base_stem}_ED.png" new_es_png = f"{fresh_base_stem}_ES.png" final_ed_frame = st.session_state.selected_ed_node['frame_idx'] if st.session_state.selected_ed_node else (hist_rec['orig_ed_frame'] if is_history_mode else -1) final_es_frame = st.session_state.selected_es_node['frame_idx'] if st.session_state.selected_es_node else (hist_rec['orig_es_frame'] if is_history_mode else -1) final_ed_cycle = st.session_state.selected_ed_node['cycle'] if st.session_state.selected_ed_node else (hist_rec['chosen_ed_cycle'] if is_history_mode else -1) final_es_cycle = st.session_state.selected_es_node['cycle'] if st.session_state.selected_es_node else (hist_rec['chosen_es_cycle'] if is_history_mode else -1) manifest_record = { "case_key": current_case_str, "original_folder": first_row['original_folder_name'], "original_stem": first_row['original_stem'], "original_view": first_row['view'], "validated_view": corrected_view, "view_status": view_status_str, "mask_status": mask_status_str, "acoustic_window_score": qc_score, "is_foreshortening": int(foreshortening_check), "chosen_ed_cycle": final_ed_cycle if should_extract else -1, "chosen_es_cycle": final_es_cycle if should_extract else -1, "orig_ed_frame": int(final_ed_frame) if should_extract else -1, "orig_es_frame": int(final_es_frame) if should_extract else -1, "final_ed_png": new_ed_png if should_extract else "", "final_es_png": new_es_png if should_extract else "", "annotation_completed": int(target_ed_mask_path.exists() and target_es_mask_path.exists()) if should_extract else 0 } if should_extract: clean_p_src = st.session_state.selected_ed_node['clean_path'] if st.session_state.selected_ed_node else first_row['clean_video_path'] orig_p_src = st.session_state.selected_ed_node['orig_path'] if st.session_state.selected_ed_node else first_row['original_video_path'] ok_ed = extract_clean_frame(clean_p_src, orig_p_src, final_ed_frame, FINAL_MASK_DIR / new_ed_png) ok_es = extract_clean_frame(clean_p_src, orig_p_src, final_es_frame, FINAL_MASK_DIR / new_es_png) if not (ok_ed and ok_es): st.error("❌ 提取视频干净帧失败!") return append_to_manifest(manifest_record) if current_case_str not in completed_keys: with open(progress_file, "a", encoding="utf-8") as pf: pf.write(f"{current_case_str}\n") st.rerun() # ─── 右半壁江山:多周期解绑推荐备选区 ─── with col_right_canvas: st.markdown("### 🎬 算法推荐全周期多靶点备选图库") if manual_mode: st.warning("🔒 **点选锁定中**:当前已开启手工精确键入模式。") for _, row in case_df.iterrows(): c_num = row['cycle_number'] with st.container(): st.markdown(f"#### 💓 算法推荐候选周期波段 #{c_num}") col_ed_card, col_es_card = st.columns(2) with col_ed_card: img_ed_path = CANDIDATES_DIR / row['disp_ed_filename'] frame_id_ed = f"c{c_num}_ED" st.markdown(f"**备选候选图 A** | 原视频第 `{row['orig_ed_frame']}` 帧") if img_ed_path.exists(): st.image(str(img_ed_path), use_container_width=True) if should_extract and not manual_mode: if st.session_state.selected_ed_node and st.session_state.selected_ed_node['id'] == frame_id_ed: if st.button(f"↩️ 取消选定 (ED)", key=f"cancel_ed_{frame_id_ed}", type="secondary", use_container_width=True): st.session_state.selected_ed_node = None st.rerun() elif st.session_state.selected_es_node and st.session_state.selected_es_node['id'] == frame_id_ed: if st.button(f"↩️ 取消选定 (ES)", key=f"cancel_es_{frame_id_ed}", type="secondary", use_container_width=True): st.session_state.selected_es_node = None st.rerun() else: if not st.session_state.selected_ed_node: if st.button("🔴 选定为 ED", key=f"select_ed_{frame_id_ed}", type="primary", use_container_width=True): st.session_state.selected_ed_node = { "id": frame_id_ed, "frame_idx": row['orig_ed_frame'], "cycle": c_num, "desc": f"周期#{c_num} 图A (第{row['orig_ed_frame']}帧)", "clean_path": row['clean_video_path'], "orig_path": row['original_video_path'] } st.rerun() elif not st.session_state.selected_es_node: if st.button("🟢 选定为 ES", key=f"select_es_{frame_id_ed}", type="primary", use_container_width=True): st.session_state.selected_es_node = { "id": frame_id_ed, "frame_idx": row['orig_ed_frame'], "cycle": c_num, "desc": f"周期#{c_num} 图A (第{row['orig_ed_frame']}帧)", "clean_path": row['clean_video_path'], "orig_path": row['original_video_path'] } st.rerun() with col_es_card: img_es_path = CANDIDATES_DIR / row['disp_es_filename'] frame_id_es = f"c{c_num}_ES" st.markdown(f"**备选候选图 B** | 原视频第 `{row['orig_es_frame']}` 帧") if img_es_path.exists(): st.image(str(img_es_path), use_container_width=True) if should_extract and not manual_mode: if st.session_state.selected_ed_node and st.session_state.selected_ed_node['id'] == frame_id_es: if st.button(f"↩️ 取消选定 (ED)", key=f"cancel_ed_{frame_id_es}", type="secondary", use_container_width=True): st.session_state.selected_ed_node = None st.rerun() elif st.session_state.selected_es_node and st.session_state.selected_es_node['id'] == frame_id_es: if st.button(f"↩). 取消选定 (ES)", key=f"cancel_es_{frame_id_es}", type="secondary", use_container_width=True): st.session_state.selected_es_node = None st.rerun() else: if not st.session_state.selected_ed_node: if st.button("🔴 选定为 ED", key=f"select_ed_{frame_id_es}", type="primary", use_container_width=True): st.session_state.selected_ed_node = { "id": frame_id_es, "frame_idx": row['orig_es_frame'], "cycle": c_num, "desc": f"周期#{c_num} 图B (第{row['orig_es_frame']}帧)", "clean_path": row['clean_video_path'], "orig_path": row['original_video_path'] } st.rerun() elif not st.session_state.selected_es_node: if st.button("🟢 选定为 ES", key=f"select_es_{frame_id_es}", type="primary", use_container_width=True): st.session_state.selected_es_node = { "id": frame_id_es, "frame_idx": row['orig_es_frame'], "cycle": c_num, "desc": f"周期#{c_num} 图B (第{row['orig_es_frame']}帧)", "clean_path": row['clean_video_path'], "orig_path": row['original_video_path'] } st.rerun() st.markdown("---")

if name == "main":
main()

思考

Summarizing root causes and actions

I need to succinctly explain the root causes:

  1. Typo 质健, causing history mode.
  2. read_csv dtype issue leading to NaN/mismatch.
  3. Classification mishap due to missing manifest.
  4. Annotation completion out of sync with classification.
  5. Infinite loop causing Streamlit block. I’ll provide the code replacement with necessary fixes, ensuring it’s complete yet clear, while keeping it manageable in size. The result will show the full script with imports, paths, and no oververbosity.

Thought for 1m 45s

回答

你这个问题主要不是 LabelMe 本身,而是前端状态机和台账对账逻辑有几个硬伤:

  1. 工作舱判断有错别字:radio 里是“质检”,if 里写成了“质健”,导致页面永远进历史工作舱分支。
  2. CSV 没有强制按字符串读取000108 这类字段确实可能被 pandas 改成 18.0 后缀,造成 case_key 对不上。
  3. 分类逻辑过度依赖 review_progress.txt:只要在 progress 里、但 manifest 没找到,就被直接塞进“质量极差”,这是你看到全都进质量极差的主要原因。
  4. 掩膜完成状态没有先扫描文件夹同步:医生画完 JSON 后,页面没有在分类前统一扫描并编译 JSON,所以不会自动进入已完成。
  5. 自动播放 while True 阻塞 Streamlit:这个会让整个页面无法响应操作。下面版本把自动播放改成浏览器原生视频播放器,不再用 Python 死循环。

下面这版可以直接替换你的 review_dashboard.py。路径仍然沿用你现在的 E 盘路径。

python
# -*- coding: utf-8 -*- """ review_dashboard.py 修复重点: 1. 修复“质检/质健”错别字导致的工作舱分流错误; 2. 所有 CSV 强制按字符串读取,避免 0001 / 08 等字段丢失前导 0; 3. 不再把“progress 中存在但 manifest 缺失”的病例自动归入质量极差; 4. 页面启动和每次刷新时自动扫描 FINAL_MASK_DIR 中的 LabelMe JSON; 5. 若 ED/ES JSON 均存在且标签完整,自动编译为 CAMUS-style mask,并更新 annotation_completed; 6. LabelMe 启动时直接打开当前 ED 或 ES 图片,而不是只打开文件夹,减少医生误选图片; 7. 删除 Streamlit 内部 while True 自动播放死循环,改为浏览器原生视频播放器,页面不会被锁死。 """ import os import re import cv2 import json import time import math import base64 import hashlib import subprocess from pathlib import Path import numpy as np import pandas as pd import streamlit as st import streamlit.components.v1 as components # ========================================================= # 0. 全局配置 # ========================================================= st.set_page_config( layout="wide", page_title="左心室黄金关键帧人机交互盲审系统" ) CANDIDATES_DIR = Path(r"E:\echo_project\data\frontend_candidates") REGISTRY_CSV = CANDIDATES_DIR / "candidates_registry.csv" FINAL_MASK_DIR = Path(r"E:\echo_project\data\final_mask_ready_library") FINAL_MASK_DIR.mkdir(parents=True, exist_ok=True) MANIFEST_CSV = FINAL_MASK_DIR / "final_annotation_manifest.csv" PROGRESS_TXT = CANDIDATES_DIR / "review_progress.txt" PREVIEW_CACHE_DIR = FINAL_MASK_DIR / "_preview_mp4_cache" PREVIEW_CACHE_DIR.mkdir(parents=True, exist_ok=True) LABELME_LABELS_TXT = FINAL_MASK_DIR / "_labelme_labels.txt" # 如果你的路径有 D/E 盘迁移,这里会在原路径不存在时自动尝试映射。 DRIVE_REMAP = { "D:": "E:", } # 是否要求医生同时画 lv_endo 和 lv_epi。 # 如果你当前只要求医生画 LV blood pool,可以改成 False。 REQUIRE_EPI_AND_ENDO = True # ========================================================= # 1. 通用工具 # ========================================================= def safe_str(x): if x is None: return "" if isinstance(x, float) and np.isnan(x): return "" s = str(x).strip() if s.lower() in ["nan", "none", "nat"]: return "" return s def normalize_case_key(x): """ 保留原始字符串主体,但处理常见 pandas 污染: 0001 -> 0001 1.0 -> 1 """ s = safe_str(x) if re.fullmatch(r"\d+\.0", s): s = s[:-2] return s.strip() def loose_case_key(x): """ 用于兼容旧台账: 0001 和 1 视为同一患者级 key。 但 0001_08 这种包含下划线的文件级 key 不会被压缩。 """ s = normalize_case_key(x) if s.isdigit(): try: return str(int(s)) except Exception: return s return s.lower() def read_csv_as_str(path: Path): if not path.exists(): return pd.DataFrame() try: return pd.read_csv( path, dtype=str, keep_default_na=False, encoding="utf-8-sig" ) except UnicodeDecodeError: return pd.read_csv( path, dtype=str, keep_default_na=False, encoding="gbk" ) def write_csv_utf8sig(df: pd.DataFrame, path: Path): path.parent.mkdir(parents=True, exist_ok=True) df.to_csv(path, index=False, encoding="utf-8-sig") def remap_path_if_needed(p): s = safe_str(p) if not s: return s pp = Path(s) if pp.exists(): return str(pp) for src, dst in DRIVE_REMAP.items(): if s.upper().startswith(src.upper()): alt = dst + s[2:] if Path(alt).exists(): return alt return s def sanitize_filename_part(s): """ Windows 文件名非法字符替换。 保留中文、空格、加号、下划线。 """ s = safe_str(s) s = re.sub(r'[<>:"/\\|?*]', "_", s) s = re.sub(r"\s+", " ", s).strip() return s def make_base_stem(original_folder, original_stem, validated_view): return ( f"{sanitize_filename_part(original_folder)}_" f"{sanitize_filename_part(original_stem)}_" f"{sanitize_filename_part(validated_view)}" ) def file_mtime(path: Path): try: return os.path.getmtime(path) except Exception: return 0.0 def get_video_frame_count(video_path: Path): cap = cv2.VideoCapture(str(video_path)) if not cap.isOpened(): return 500 n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) cap.release() if n <= 0: return 500 return n def ensure_labelme_labels_file(): """ LabelMe 的 --labels 参数通常需要一个 labels.txt 文件,而不是逗号字符串。 """ if not LABELME_LABELS_TXT.exists(): labels = [ "__ignore__", "_background_", "lv_endo", "lv_epi", ] LABELME_LABELS_TXT.write_text("\n".join(labels), encoding="utf-8") return LABELME_LABELS_TXT def launch_labelme_for_image(image_path: Path): labels_txt = ensure_labelme_labels_file() cmd = [ "labelme", str(image_path), "--labels", str(labels_txt), "--nodata", ] subprocess.Popen(cmd, shell=False) # ========================================================= # 2. 台账读取与标准化 # ========================================================= @st.cache_data(show_spinner=False) def load_registry_cached(registry_csv_str, mtime): registry_csv = Path(registry_csv_str) df = read_csv_as_str(registry_csv) if df.empty: return df # 防止列名里混入空格 df.columns = [safe_str(c) for c in df.columns] # 兼容字段缺失 if "case_key" not in df.columns: if "New_Filename" in df.columns: df["case_key"] = df["New_Filename"].apply(lambda x: Path(safe_str(x)).stem) else: df["case_key"] = [f"case_{i:04d}" for i in range(len(df))] df["case_key"] = df["case_key"].apply(normalize_case_key) df["case_key_loose"] = df["case_key"].apply(loose_case_key) for col in ["clean_video_path", "original_video_path"]: if col in df.columns: df[col] = df[col].apply(remap_path_if_needed) else: df[col] = "" if "original_folder_name" not in df.columns: if "original_video_path" in df.columns: df["original_folder_name"] = df["original_video_path"].apply( lambda x: Path(safe_str(x)).parent.name if safe_str(x) else "" ) else: df["original_folder_name"] = "" if "original_stem" not in df.columns: if "original_video_path" in df.columns: df["original_stem"] = df["original_video_path"].apply( lambda x: Path(safe_str(x)).stem if safe_str(x) else "" ) else: df["original_stem"] = "" if "view" not in df.columns: df["view"] = "Unknown" if "cycle_number" not in df.columns: df["cycle_number"] = "0" # 排序时尽量按数字周期排 df["_cycle_sort"] = pd.to_numeric(df["cycle_number"], errors="coerce").fillna(0) return df def load_registry(): return load_registry_cached(str(REGISTRY_CSV), file_mtime(REGISTRY_CSV)) def load_manifest(): df = read_csv_as_str(MANIFEST_CSV) if df.empty: return pd.DataFrame() df.columns = [safe_str(c) for c in df.columns] if "case_key" not in df.columns: df["case_key"] = "" df["case_key"] = df["case_key"].apply(normalize_case_key) df["case_key_loose"] = df["case_key"].apply(loose_case_key) for col in [ "original_folder", "original_stem", "original_view", "validated_view", "view_status", "mask_status", "base_stem", "final_ed_png", "final_es_png", "annotation_completed", ]: if col not in df.columns: df[col] = "" return df def save_manifest(df: pd.DataFrame): df = df.copy() if "case_key_loose" in df.columns: df = df.drop(columns=["case_key_loose"]) write_csv_utf8sig(df, MANIFEST_CSV) def get_manifest_record(manifest_df: pd.DataFrame, case_key): if manifest_df.empty: return None lk = loose_case_key(case_key) hit = manifest_df[manifest_df["case_key_loose"] == lk] if hit.empty: return None return hit.iloc[-1].to_dict() def append_to_manifest(data_dict): """ 覆盖同一 case_key 的旧记录。 用 loose_case_key 做兼容,避免旧表里 0001 / 1 不一致。 """ old = load_manifest() lk = loose_case_key(data_dict["case_key"]) if not old.empty: old = old[old["case_key_loose"] != lk].copy() if "case_key_loose" in old.columns: old = old.drop(columns=["case_key_loose"]) row = pd.DataFrame([data_dict]) new_df = pd.concat([old, row], ignore_index=True) save_manifest(new_df) def load_progress_keys(): if not PROGRESS_TXT.exists(): return [] try: with open(PROGRESS_TXT, "r", encoding="utf-8") as f: return [normalize_case_key(line) for line in f.read().splitlines() if line.strip()] except Exception: return [] def append_progress_key(case_key): existing = set([loose_case_key(x) for x in load_progress_keys()]) if loose_case_key(case_key) not in existing: PROGRESS_TXT.parent.mkdir(parents=True, exist_ok=True) with open(PROGRESS_TXT, "a", encoding="utf-8") as f: f.write(f"{normalize_case_key(case_key)}\n") # ========================================================= # 3. LabelMe JSON -> CAMUS Mask # ========================================================= def _polygon_to_binary_bitmask(points, h, w): mask = np.zeros((h, w), dtype=np.uint8) if not points or len(points) < 3: return mask pts = np.array(points, dtype=np.int32) cv2.fillPoly(mask, [pts], 1) return mask def _label_norm(label): label = safe_str(label).strip().lower() label = label.replace("-", "_").replace(" ", "_") return label def _is_endo_label(label): label = _label_norm(label) return label in [ "lv_endo", "endo", "endocardium", "lv_bloodpool", "lv_blood_pool", "bloodpool", "blood_pool", "lv", ] def _is_epi_label(label): label = _label_norm(label) return label in [ "lv_epi", "epi", "epicardium", "lv_myocardium_outer", "outer_wall", ] def auto_compile_labelme_json_to_camus_mask(json_path: Path, out_mask_path: Path): """ 返回: { "ok": bool, "has_endo": bool, "has_epi": bool, "message": str } """ result = { "ok": False, "has_endo": False, "has_epi": False, "message": "", } if not json_path.exists(): result["message"] = "json_not_exists" return result try: with open(json_path, "r", encoding="utf-8") as f: meta = json.load(f) h = int(meta.get("imageHeight", 0)) w = int(meta.get("imageWidth", 0)) if h <= 0 or w <= 0: result["message"] = "invalid_image_size" return result epi_mask = np.zeros((h, w), dtype=np.uint8) endo_mask = np.zeros((h, w), dtype=np.uint8) for shp in meta.get("shapes", []): label = shp.get("label", "") points = shp.get("points", []) if _is_endo_label(label): endo_mask = np.maximum(endo_mask, _polygon_to_binary_bitmask(points, h, w)) result["has_endo"] = True elif _is_epi_label(label): epi_mask = np.maximum(epi_mask, _polygon_to_binary_bitmask(points, h, w)) result["has_epi"] = True if REQUIRE_EPI_AND_ENDO: if not result["has_endo"] or not result["has_epi"]: result["message"] = "missing_lv_endo_or_lv_epi" return result else: if not result["has_endo"]: result["message"] = "missing_lv_endo" return result camus_mask = np.zeros((h, w), dtype=np.uint8) if result["has_epi"]: myocardium_mask = ((epi_mask > 0) & (endo_mask == 0)).astype(np.uint8) camus_mask[myocardium_mask > 0] = 2 camus_mask[endo_mask > 0] = 1 out_mask_path.parent.mkdir(parents=True, exist_ok=True) cv2.imwrite(str(out_mask_path), camus_mask) result["ok"] = True result["message"] = "compiled" return result except Exception as e: result["message"] = f"compile_error: {e}" return result def record_base_stem(rec): base = safe_str(rec.get("base_stem", "")) if base: return base folder = safe_str(rec.get("original_folder", "")) stem = safe_str(rec.get("original_stem", "")) view = safe_str(rec.get("validated_view", "")) if not view: view = safe_str(rec.get("original_view", "")) return make_base_stem(folder, stem, view) def assets_for_base_stem(base_stem): return { "ed_img": FINAL_MASK_DIR / f"{base_stem}_ED.png", "es_img": FINAL_MASK_DIR / f"{base_stem}_ES.png", "ed_json": FINAL_MASK_DIR / f"{base_stem}_ED.json", "es_json": FINAL_MASK_DIR / f"{base_stem}_ES.json", "ed_mask": FINAL_MASK_DIR / f"{base_stem}_ED_mask.png", "es_mask": FINAL_MASK_DIR / f"{base_stem}_ES_mask.png", } def sync_manifest_with_labelme_outputs(manifest_df: pd.DataFrame): """ 每次页面启动/刷新时调用: 1. 根据 manifest 记录定位 ED/ES JSON; 2. 若 JSON 存在,自动编译 mask; 3. 若双期 mask 都存在,annotation_completed=1; 4. 将状态写回 manifest。 """ if manifest_df.empty: return manifest_df df = manifest_df.copy() changed = False required_cols = [ "base_stem", "ed_json_exists", "es_json_exists", "ed_mask_exists", "es_mask_exists", "ed_json_status", "es_json_status", "annotation_completed", ] for col in required_cols: if col not in df.columns: df[col] = "" for idx, row in df.iterrows(): rec = row.to_dict() base = record_base_stem(rec) if safe_str(df.at[idx, "base_stem"]) != base: df.at[idx, "base_stem"] = base changed = True view_status = safe_str(row.get("view_status", "")).lower() mask_status = safe_str(row.get("mask_status", "")).lower() # 质量熔断病例不参与掩膜完成状态 if view_status == "unidentifiable" or mask_status == "unlabelable": if safe_str(df.at[idx, "annotation_completed"]) != "0": df.at[idx, "annotation_completed"] = "0" changed = True continue assets = assets_for_base_stem(base) ed_status = "" es_status = "" if assets["ed_json"].exists(): r = auto_compile_labelme_json_to_camus_mask( assets["ed_json"], assets["ed_mask"] ) ed_status = r["message"] if assets["es_json"].exists(): r = auto_compile_labelme_json_to_camus_mask( assets["es_json"], assets["es_mask"] ) es_status = r["message"] new_values = { "ed_json_exists": int(assets["ed_json"].exists()), "es_json_exists": int(assets["es_json"].exists()), "ed_mask_exists": int(assets["ed_mask"].exists()), "es_mask_exists": int(assets["es_mask"].exists()), "ed_json_status": ed_status, "es_json_status": es_status, "annotation_completed": int(assets["ed_mask"].exists() and assets["es_mask"].exists()), } for k, v in new_values.items(): old_v = safe_str(df.at[idx, k]) if old_v != safe_str(v): df.at[idx, k] = v changed = True if changed: save_manifest(df) if "case_key_loose" not in df.columns: df["case_key_loose"] = df["case_key"].apply(loose_case_key) return df # ========================================================= # 4. 视频读取与预览 # ========================================================= @st.cache_data(show_spinner=False) def load_video_frames(video_path_str, mtime): frames = [] try: cap = cv2.VideoCapture(video_path_str) while True: ok, frame = cap.read() if not ok: break frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) cap.release() except Exception: return [] return frames @st.cache_data(show_spinner=False) def transcode_preview_mp4(video_path_str, mtime): """ 将 AVI 转成浏览器可直接播放的 mp4,避免 Streamlit while True 锁死页面。 """ video_path = Path(video_path_str) key = hashlib.md5( f"{video_path_str}_{mtime}".encode("utf-8", errors="ignore") ).hexdigest() out_path = PREVIEW_CACHE_DIR / f"{key}.mp4" if out_path.exists() and out_path.stat().st_size > 0: return str(out_path) cap = cv2.VideoCapture(str(video_path)) if not cap.isOpened(): return "" fps = cap.get(cv2.CAP_PROP_FPS) if fps is None or fps <= 1 or fps > 120 or math.isnan(fps): fps = 50.0 w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) if w <= 0 or h <= 0: cap.release() return "" # mp4 对奇数宽高不友好 out_w = w if w % 2 == 0 else w - 1 out_h = h if h % 2 == 0 else h - 1 fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter(str(out_path), fourcc, fps, (out_w, out_h)) if not writer.isOpened(): cap.release() return "" while True: ok, frame = cap.read() if not ok: break if frame.shape[1] != out_w or frame.shape[0] != out_h: frame = cv2.resize(frame, (out_w, out_h), interpolation=cv2.INTER_AREA) writer.write(frame) writer.release() cap.release() if out_path.exists() and out_path.stat().st_size > 0: return str(out_path) return "" def render_browser_video_player(video_path: Path): """ 浏览器原生播放器: - 自动循环; - 不阻塞 Streamlit; - 播放期间可以操作页面其他按钮。 """ mp4_path_str = transcode_preview_mp4(str(video_path), file_mtime(video_path)) if not mp4_path_str: st.warning("浏览器预览视频转码失败,请切换到手动飞梭模式。") return mp4_path = Path(mp4_path_str) try: video_bytes = mp4_path.read_bytes() encoded = base64.b64encode(video_bytes).decode("utf-8") html = f""" <video width="100%" controls autoplay loop muted playsinline style="border-radius: 8px; border: 1px solid #ddd;" > <source src="data:video/mp4;base64,{encoded}" type="video/mp4"> 当前浏览器无法播放该视频。 </video> """ components.html(html, height=430) except Exception as e: st.warning(f"浏览器视频播放器加载失败:{e}") # ========================================================= # 5. 干净帧提取 # ========================================================= def extract_clean_frame(clean_video_path, orig_video_path, orig_target_frame, output_png_path): """ 根据原视频帧号与帧率自适应换算,提取 Accepted 纯净视频对应帧。 """ clean_video_path = Path(remap_path_if_needed(clean_video_path)) orig_video_path = Path(remap_path_if_needed(orig_video_path)) cap_orig = cv2.VideoCapture(str(orig_video_path)) orig_fps = cap_orig.get(cv2.CAP_PROP_FPS) if orig_fps is None or orig_fps <= 1 or orig_fps > 300 or math.isnan(orig_fps): orig_fps = 50.0 cap_orig.release() cap_clean = cv2.VideoCapture(str(clean_video_path)) if not cap_clean.isOpened(): return False clean_fps = cap_clean.get(cv2.CAP_PROP_FPS) if clean_fps is None or clean_fps <= 1 or clean_fps > 300 or math.isnan(clean_fps): clean_fps = 50.0 clean_n = int(cap_clean.get(cv2.CAP_PROP_FRAME_COUNT)) if clean_n <= 0: cap_clean.release() return False time_sec = int(orig_target_frame) / float(orig_fps) clean_frame_idx = int(np.clip(round(time_sec * clean_fps), 0, clean_n - 1)) cap_clean.set(cv2.CAP_PROP_POS_FRAMES, clean_frame_idx) ok, frame = cap_clean.read() cap_clean.release() if ok: output_png_path.parent.mkdir(parents=True, exist_ok=True) cv2.imencode(".png", frame)[1].tofile(str(output_png_path)) return True return False # ========================================================= # 6. 病例状态池构建 # ========================================================= def build_case_pools(registry_df: pd.DataFrame, manifest_df: pd.DataFrame): all_case_df = ( registry_df[["case_key", "case_key_loose"]] .drop_duplicates() .reset_index(drop=True) ) all_cases = all_case_df["case_key"].tolist() all_loose_set = set(all_case_df["case_key_loose"].tolist()) if manifest_df.empty: return all_cases, all_cases, [], [], [] manifest_valid = manifest_df[manifest_df["case_key_loose"].isin(all_loose_set)].copy() reviewed_loose = set(manifest_valid["case_key_loose"].tolist()) pending_cases = [ row["case_key"] for _, row in all_case_df.iterrows() if row["case_key_loose"] not in reviewed_loose ] loose_to_display = dict(zip(all_case_df["case_key_loose"], all_case_df["case_key"])) drawn_cases = [] undrawn_cases = [] unlabelable_cases = [] # 同一个 case 只取最后一条 manifest manifest_last = ( manifest_valid .drop_duplicates(subset=["case_key_loose"], keep="last") .reset_index(drop=True) ) for _, row in manifest_last.iterrows(): lk = row["case_key_loose"] display_key = loose_to_display.get(lk, row["case_key"]) view_status = safe_str(row.get("view_status", "")).lower() mask_status = safe_str(row.get("mask_status", "")).lower() completed = safe_str(row.get("annotation_completed", "0")) if view_status == "unidentifiable" or mask_status == "unlabelable": unlabelable_cases.append(display_key) elif completed == "1": drawn_cases.append(display_key) else: undrawn_cases.append(display_key) return all_cases, pending_cases, undrawn_cases, drawn_cases, unlabelable_cases # ========================================================= # 7. 主界面 # ========================================================= def main(): if not REGISTRY_CSV.exists(): st.error(f"运行中断:找不到候选帧注册表:{REGISTRY_CSV}") return df = load_registry() if df.empty: st.error("candidates_registry.csv 为空或读取失败。") return manifest_df = load_manifest() manifest_df = sync_manifest_with_labelme_outputs(manifest_df) all_cases, pending_cases, undrawn_cases, drawn_cases, unlabelable_cases = build_case_pools( df, manifest_df ) st.sidebar.title("核心质检与标注流水线") st.sidebar.metric("全库解算总样本", f"{len(all_cases)} 个") st.sidebar.metric("待关键帧质检", f"{len(pending_cases)} 个") st.sidebar.metric("待掩膜标注", f"{len(undrawn_cases)} 个") st.sidebar.metric("已绘制完成", f"{len(drawn_cases)} 个") st.sidebar.metric("质量熔断", f"{len(unlabelable_cases)} 个") if st.sidebar.button("刷新掩膜 JSON / 进度状态", use_container_width=True): manifest_df = load_manifest() manifest_df = sync_manifest_with_labelme_outputs(manifest_df) st.rerun() workspace = st.sidebar.radio( "选择当前主工作舱", [ "关键帧质检对账舱 (待评审)", "密集掩膜标注舱 (已入库历史)", ] ) is_history_mode = False if workspace == "关键帧质检对账舱 (待评审)": if not pending_cases: st.success("当前队列中所有样本已完成关键帧质检。") return current_case = st.sidebar.selectbox( "选定当前待核对病例", pending_cases ) else: is_history_mode = True sub_filter = st.sidebar.radio( "筛选历史个体状态", [ f"待标注心内心外膜 ({len(undrawn_cases)}个)", f"已绘制完成 / 任务结束 ({len(drawn_cases)}个)", f"质量极差 / 模糊免绘 ({len(unlabelable_cases)}个)", ] ) if "待标注" in sub_filter: target_pool = undrawn_cases elif "已绘制完成" in sub_filter: target_pool = drawn_cases else: target_pool = unlabelable_cases if not target_pool: st.sidebar.info("当前筛选分类下暂无病例。") fallback_pool = all_cases current_case = st.sidebar.selectbox( "调阅全量病例", fallback_pool ) else: current_case = st.sidebar.selectbox( "选定待标注 / 核对历史目标", target_pool ) current_case_str = normalize_case_key(current_case) current_case_loose = loose_case_key(current_case_str) case_df = ( df[df["case_key_loose"] == current_case_loose] .sort_values(by="_cycle_sort") .reset_index(drop=True) ) if case_df.empty: st.error(f"在 candidates_registry.csv 中找不到病例:{current_case_str}") return first_row = case_df.iloc[0] hist_rec = get_manifest_record(manifest_df, current_case_str) # ===================================================== # 7.1 状态恢复 # ===================================================== if "prev_case" not in st.session_state or st.session_state.prev_case != current_case_str: st.session_state.prev_case = current_case_str st.session_state.selected_ed_node = None st.session_state.selected_es_node = None default_view = safe_str(first_row.get("view", "Unknown")) saved_view = default_view v_idx = 0 m_idx = 0 score_val = 4 fs_val = False if hist_rec is not None: v_status = safe_str(hist_rec.get("view_status", "accepted")).lower() m_status = safe_str(hist_rec.get("mask_status", "labelable")).lower() try: score_val = int(float(safe_str(hist_rec.get("acoustic_window_score", "4")))) except Exception: score_val = 4 fs_val = safe_str(hist_rec.get("is_foreshortening", "0")) in ["1", "true", "True"] saved_view = safe_str(hist_rec.get("validated_view", default_view)) or default_view if v_status == "unidentifiable": v_idx = 2 elif v_status == "corrected": v_idx = 1 else: v_idx = 0 m_idx = 1 if m_status == "unlabelable" else 0 base_stem = ( safe_str(hist_rec.get("base_stem", "")) if hist_rec else "" ) if not base_stem: base_stem = make_base_stem( first_row.get("original_folder_name", ""), first_row.get("original_stem", ""), saved_view, ) assets = assets_for_base_stem(base_stem) # ===================================================== # 7.2 侧边栏 LabelMe 联动 # ===================================================== with st.sidebar.expander("实时掩膜绘制工作台", expanded=True): st.markdown("### LabelMe 掩膜工作台") st.caption("完成关键帧质检后,可直接打开 ED / ES 图片进行绘制。") # 当前病例再次即时同步,保证刚画完的 JSON 能被立刻识别 if assets["ed_json"].exists(): auto_compile_labelme_json_to_camus_mask(assets["ed_json"], assets["ed_mask"]) if assets["es_json"].exists(): auto_compile_labelme_json_to_camus_mask(assets["es_json"], assets["es_mask"]) ed_done = assets["ed_mask"].exists() es_done = assets["es_mask"].exists() if assets["ed_img"].exists(): st.markdown( f"**ED 状态**:{'已绘制完成' if ed_done else '未绘制 / 待绘制'}" ) if st.button( "启动 LabelMe 绘制 ED 掩膜", key=f"draw_ed_{current_case_loose}", use_container_width=True, ): launch_labelme_for_image(assets["ed_img"]) st.toast("已启动 LabelMe:ED 图片") else: st.caption("ED 纯净帧尚未生成。请先完成关键帧质检提交。") if assets["es_img"].exists(): st.markdown( f"**ES 状态**:{'已绘制完成' if es_done else '未绘制 / 待绘制'}" ) if st.button( "启动 LabelMe 绘制 ES 掩膜", key=f"draw_es_{current_case_loose}", use_container_width=True, ): launch_labelme_for_image(assets["es_img"]) st.toast("已启动 LabelMe:ES 图片") else: st.caption("ES 纯净帧尚未生成。请先完成关键帧质检提交。") if ed_done and es_done: st.success("该个体 ED / ES 双期掩膜已完成。") if hist_rec is not None and safe_str(hist_rec.get("annotation_completed", "0")) != "1": updated = dict(hist_rec) updated["case_key"] = current_case_str updated["annotation_completed"] = 1 append_to_manifest(updated) # ===================================================== # 7.3 主体布局 # ===================================================== col_left_panel, col_right_canvas = st.columns([2, 3]) with col_left_panel: st.markdown("### 动态超声观测与决策中心") orig_video_p = Path(remap_path_if_needed(first_row.get("original_video_path", ""))) if orig_video_p.exists(): total_f_for_case = get_video_frame_count(orig_video_p) st.markdown( f"**原始影像控制台** | 归属:`{safe_str(first_row.get('original_folder_name', ''))}`" ) play_mode = st.radio( "视频播放控制模式", [ "浏览器原生循环播放", "PACS 手动飞梭", ], key=f"pmode_{current_case_loose}", horizontal=True, ) if play_mode == "浏览器原生循环播放": render_browser_video_player(orig_video_p) st.caption("该播放器由浏览器独立播放,不会阻塞页面按钮、表单和 LabelMe 操作。") else: video_frames = load_video_frames(str(orig_video_p), file_mtime(orig_video_p)) if video_frames: total_f = len(video_frames) scrub_idx = st.slider( "拖动滑块精准查看原始帧", 0, total_f - 1, 0, key=f"scrub_{current_case_loose}", ) st.image( video_frames[scrub_idx], caption=f"原始影像当前帧:第 {scrub_idx} 帧 / 共 {total_f} 帧", use_container_width=True, ) else: st.error("该原始视频无法解析帧序列。") else: total_f_for_case = 500 st.warning(f"未检测到原始带心电图视频:{orig_video_p}") local_dump_folder = Path(r"E:\echo_project\data\latency_calibration") / orig_video_p.stem st.markdown("**本地全量原始帧拆解目录**") st.code(str(local_dump_folder), language="text") st.markdown("---") st.markdown("### 双轨寻帧模式切换") manual_mode = st.checkbox( "开启纯手工精确键入帧号模式", key=f"manual_{current_case_loose}", ) if manual_mode: st.caption("请输入原视频对应的 ED / ES 帧号。") m_col1, m_col2 = st.columns(2) with m_col1: default_ed = 0 if hist_rec is not None: try: default_ed = int(float(safe_str(hist_rec.get("orig_ed_frame", "0")))) except Exception: default_ed = 0 man_ed = st.number_input( "手动指定 ED 帧号", min_value=0, max_value=max(0, total_f_for_case - 1), value=int(np.clip(default_ed, 0, max(0, total_f_for_case - 1))), key=f"med_{current_case_loose}", ) st.session_state.selected_ed_node = { "id": "manual_ed", "frame_idx": int(man_ed), "cycle": "Manual", "desc": f"专家手工指定 ED:原视频第 {int(man_ed)} 帧", "clean_path": safe_str(first_row.get("clean_video_path", "")), "orig_path": safe_str(first_row.get("original_video_path", "")), } with m_col2: default_es = min(20, max(0, total_f_for_case - 1)) if hist_rec is not None: try: default_es = int(float(safe_str(hist_rec.get("orig_es_frame", "20")))) except Exception: default_es = min(20, max(0, total_f_for_case - 1)) man_es = st.number_input( "手动指定 ES 帧号", min_value=0, max_value=max(0, total_f_for_case - 1), value=int(np.clip(default_es, 0, max(0, total_f_for_case - 1))), key=f"mes_{current_case_loose}", ) st.session_state.selected_es_node = { "id": "manual_es", "frame_idx": int(man_es), "cycle": "Manual", "desc": f"专家手工指定 ES:原视频第 {int(man_es)} 帧", "clean_path": safe_str(first_row.get("clean_video_path", "")), "orig_path": safe_str(first_row.get("original_video_path", "")), } st.markdown("**黄金关键帧当前捕获状态**") if st.session_state.selected_ed_node: st.error(f"ED:{st.session_state.selected_ed_node['desc']}") else: st.markdown("ED 状态:待捕获") if st.session_state.selected_es_node: st.success(f"ES:{st.session_state.selected_es_node['desc']}") else: st.markdown("ES 状态:待捕获") if not manual_mode and (st.session_state.selected_ed_node or st.session_state.selected_es_node): if st.button("清空当前点击状态", use_container_width=True): st.session_state.selected_ed_node = None st.session_state.selected_es_node = None st.rerun() st.markdown("---") st.markdown("### 临床切面与质量评估控制台") view_check = st.radio( f"1. 切面分类核对结果:当前预判 {safe_str(first_row.get('view', 'Unknown'))}", [ "正确", "错误 (需下方修正)", "模糊/非标切面 (彻底无法分类)", ], index=v_idx, key=f"vcheck_{current_case_loose}", ) corrected_view = saved_view if "错误" in view_check: views = ["A2C", "A3C", "A4C"] default_corrected_idx = views.index(saved_view) if saved_view in views else 0 corrected_view = st.selectbox( "请选择该病例实际对应的正确切面标签", views, index=default_corrected_idx, key=f"corr_view_select_{current_case_loose}", ) elif "模糊" in view_check: corrected_view = "Unidentifiable" mask_check = st.radio( "2. 掩膜标注可行性评估", [ "质量合格 (可绘制心内膜/心外膜掩膜)", "质量极差 (声窗盲区/拒绝标注掩膜)", ], index=m_idx, key=f"mcheck_{current_case_loose}", ) qc_score = st.slider( "3. 声窗图像质量评分", min_value=1, max_value=5, value=int(np.clip(score_val, 1, 5)), step=1, key=f"qcs_{current_case_loose}", ) foreshortening_check = st.checkbox( "存在心尖随动缩短 Foreshortening", value=fs_val, key=f"fs_{current_case_loose}", ) st.markdown("---") is_view_unidentifiable = "模糊" in view_check is_mask_unlabelable = "质量极差" in mask_check should_extract = (not is_view_unidentifiable) and (not is_mask_unlabelable) if should_extract: if st.session_state.selected_ed_node and st.session_state.selected_es_node: st.success(f"校验通过:保存目标切面标签为 [{corrected_view}]") can_submit = True elif is_history_mode and hist_rec is not None: st.info(f"历史调阅模式:当前正在复核 [{corrected_view}] 标签资产。") can_submit = True else: st.error("必须完成 ED / ES 两帧捕获后方可提交。") can_submit = False else: st.warning("熔断模式:当前案例触发质量问题,仅保留台账,不生成待绘制图片。") can_submit = True if st.button( "提交质检结论并放行下一例", type="primary", use_container_width=True, disabled=not can_submit, ): if "模糊" in view_check: view_status_str = "unidentifiable" elif "错误" in view_check: view_status_str = "corrected" else: view_status_str = "accepted" mask_status_str = "unlabelable" if is_mask_unlabelable else "labelable" fresh_base_stem = make_base_stem( first_row.get("original_folder_name", ""), first_row.get("original_stem", ""), corrected_view, ) new_assets = assets_for_base_stem(fresh_base_stem) if st.session_state.selected_ed_node: final_ed_frame = int(st.session_state.selected_ed_node["frame_idx"]) final_ed_cycle = st.session_state.selected_ed_node["cycle"] elif hist_rec is not None: final_ed_frame = int(float(safe_str(hist_rec.get("orig_ed_frame", "-1")) or -1)) final_ed_cycle = safe_str(hist_rec.get("chosen_ed_cycle", "-1")) else: final_ed_frame = -1 final_ed_cycle = -1 if st.session_state.selected_es_node: final_es_frame = int(st.session_state.selected_es_node["frame_idx"]) final_es_cycle = st.session_state.selected_es_node["cycle"] elif hist_rec is not None: final_es_frame = int(float(safe_str(hist_rec.get("orig_es_frame", "-1")) or -1)) final_es_cycle = safe_str(hist_rec.get("chosen_es_cycle", "-1")) else: final_es_frame = -1 final_es_cycle = -1 need_extract_now = ( should_extract and st.session_state.selected_ed_node is not None and st.session_state.selected_es_node is not None ) if should_extract and not is_history_mode and not need_extract_now: st.error("当前是待评审模式,但没有选择 ED / ES,无法生成纯净帧。") return if need_extract_now: clean_p_src = st.session_state.selected_ed_node["clean_path"] orig_p_src = st.session_state.selected_ed_node["orig_path"] ok_ed = extract_clean_frame( clean_p_src, orig_p_src, final_ed_frame, new_assets["ed_img"], ) ok_es = extract_clean_frame( clean_p_src, orig_p_src, final_es_frame, new_assets["es_img"], ) if not (ok_ed and ok_es): st.error("提取纯净 ED / ES 帧失败,请检查 clean_video_path 和 original_video_path。") return # 提交前再检查一次当前 base 的掩膜状态 if new_assets["ed_json"].exists(): auto_compile_labelme_json_to_camus_mask( new_assets["ed_json"], new_assets["ed_mask"], ) if new_assets["es_json"].exists(): auto_compile_labelme_json_to_camus_mask( new_assets["es_json"], new_assets["es_mask"], ) annotation_completed_now = int( should_extract and new_assets["ed_mask"].exists() and new_assets["es_mask"].exists() ) manifest_record = { "case_key": current_case_str, "base_stem": fresh_base_stem, "original_folder": safe_str(first_row.get("original_folder_name", "")), "original_stem": safe_str(first_row.get("original_stem", "")), "original_view": safe_str(first_row.get("view", "")), "validated_view": corrected_view, "view_status": view_status_str, "mask_status": mask_status_str, "acoustic_window_score": int(qc_score), "is_foreshortening": int(foreshortening_check), "chosen_ed_cycle": final_ed_cycle if should_extract else -1, "chosen_es_cycle": final_es_cycle if should_extract else -1, "orig_ed_frame": int(final_ed_frame) if should_extract else -1, "orig_es_frame": int(final_es_frame) if should_extract else -1, "final_ed_png": new_assets["ed_img"].name if should_extract else "", "final_es_png": new_assets["es_img"].name if should_extract else "", "ed_json_exists": int(new_assets["ed_json"].exists()), "es_json_exists": int(new_assets["es_json"].exists()), "ed_mask_exists": int(new_assets["ed_mask"].exists()), "es_mask_exists": int(new_assets["es_mask"].exists()), "annotation_completed": annotation_completed_now, } append_to_manifest(manifest_record) append_progress_key(current_case_str) st.success("已提交并更新台账。") time.sleep(0.3) st.rerun() # ===================================================== # 7.4 右侧候选图库 # ===================================================== with col_right_canvas: st.markdown("### 算法推荐全周期多靶点备选图库") if manual_mode: st.warning("当前已开启手工精确键入模式,右侧点击选帧已锁定。") for _, row in case_df.iterrows(): c_num = safe_str(row.get("cycle_number", "0")) with st.container(): st.markdown(f"#### 算法推荐候选周期 #{c_num}") col_ed_card, col_es_card = st.columns(2) # ------------------------- # ED 候选图 # ------------------------- with col_ed_card: disp_ed_filename = safe_str(row.get("disp_ed_filename", "")) img_ed_path = CANDIDATES_DIR / disp_ed_filename frame_id_ed = f"{current_case_loose}_c{c_num}_ED" st.markdown( f"**候选图 A** | 原视频第 `{safe_str(row.get('orig_ed_frame', ''))}` 帧" ) if img_ed_path.exists(): st.image(str(img_ed_path), use_container_width=True) else: st.warning(f"找不到候选图:{img_ed_path.name}") if should_extract and not manual_mode: selected_ed = ( st.session_state.selected_ed_node and st.session_state.selected_ed_node["id"] == frame_id_ed ) selected_es = ( st.session_state.selected_es_node and st.session_state.selected_es_node["id"] == frame_id_ed ) if selected_ed: if st.button( "取消选定 ED", key=f"cancel_ed_{frame_id_ed}", use_container_width=True, ): st.session_state.selected_ed_node = None st.rerun() elif selected_es: if st.button( "取消选定 ES", key=f"cancel_es_{frame_id_ed}", use_container_width=True, ): st.session_state.selected_es_node = None st.rerun() else: if st.session_state.selected_ed_node is None: if st.button( "选定为 ED", key=f"select_ed_{frame_id_ed}", type="primary", use_container_width=True, ): st.session_state.selected_ed_node = { "id": frame_id_ed, "frame_idx": int(float(safe_str(row.get("orig_ed_frame", "0")) or 0)), "cycle": c_num, "desc": f"周期#{c_num} 图A,第{safe_str(row.get('orig_ed_frame', ''))}帧", "clean_path": safe_str(row.get("clean_video_path", "")), "orig_path": safe_str(row.get("original_video_path", "")), } st.rerun() elif st.session_state.selected_es_node is None: if st.button( "选定为 ES", key=f"select_es_{frame_id_ed}", type="primary", use_container_width=True, ): st.session_state.selected_es_node = { "id": frame_id_ed, "frame_idx": int(float(safe_str(row.get("orig_ed_frame", "0")) or 0)), "cycle": c_num, "desc": f"周期#{c_num} 图A,第{safe_str(row.get('orig_ed_frame', ''))}帧", "clean_path": safe_str(row.get("clean_video_path", "")), "orig_path": safe_str(row.get("original_video_path", "")), } st.rerun() # ------------------------- # ES 候选图 # ------------------------- with col_es_card: disp_es_filename = safe_str(row.get("disp_es_filename", "")) img_es_path = CANDIDATES_DIR / disp_es_filename frame_id_es = f"{current_case_loose}_c{c_num}_ES" st.markdown( f"**候选图 B** | 原视频第 `{safe_str(row.get('orig_es_frame', ''))}` 帧" ) if img_es_path.exists(): st.image(str(img_es_path), use_container_width=True) else: st.warning(f"找不到候选图:{img_es_path.name}") if should_extract and not manual_mode: selected_ed = ( st.session_state.selected_ed_node and st.session_state.selected_ed_node["id"] == frame_id_es ) selected_es = ( st.session_state.selected_es_node and st.session_state.selected_es_node["id"] == frame_id_es ) if selected_ed: if st.button( "取消选定 ED", key=f"cancel_ed_{frame_id_es}", use_container_width=True, ): st.session_state.selected_ed_node = None st.rerun() elif selected_es: if st.button( "取消选定 ES", key=f"cancel_es_{frame_id_es}", use_container_width=True, ): st.session_state.selected_es_node = None st.rerun() else: if st.session_state.selected_ed_node is None: if st.button( "选定为 ED", key=f"select_ed_{frame_id_es}", type="primary", use_container_width=True, ): st.session_state.selected_ed_node = { "id": frame_id_es, "frame_idx": int(float(safe_str(row.get("orig_es_frame", "0")) or 0)), "cycle": c_num, "desc": f"周期#{c_num} 图B,第{safe_str(row.get('orig_es_frame', ''))}帧", "clean_path": safe_str(row.get("clean_video_path", "")), "orig_path": safe_str(row.get("original_video_path", "")), } st.rerun() elif st.session_state.selected_es_node is None: if st.button( "选定为 ES", key=f"select_es_{frame_id_es}", type="primary", use_container_width=True, ): st.session_state.selected_es_node = { "id": frame_id_es, "frame_idx": int(float(safe_str(row.get("orig_es_frame", "0")) or 0)), "cycle": c_num, "desc": f"周期#{c_num} 图B,第{safe_str(row.get('orig_es_frame', ''))}帧", "clean_path": safe_str(row.get("clean_video_path", "")), "orig_path": safe_str(row.get("original_video_path", "")), } st.rerun() st.markdown("---") if __name__ == "__main__": main()

运行前建议确认依赖:

bash
pip install streamlit opencv-python numpy pandas labelme

运行:

bash
streamlit run E:\echo_project\echo_cad_workflow\find_ESED\review_dashboard.py

还有一个建议:你后续所有读取 success_log.csvcandidates_registry.csvfinal_annotation_manifest.csv 的地方,都尽量统一改成:

python
pd.read_csv(csv_path, dtype=str, keep_default_na=False, encoding="utf-8-sig")

这样可以最大程度避免 0001_08.avi000108、空字符串被 pandas 自动改类型,导致前端和实际文件对不上。

分享这个问答