我要进行心脏超声特征提取实现冠心病预测的任务,现在用EchoNet-Dynamic的开源进行了分割,...
Creato il: 25 maggio 2026
Risposto usando GPT-5.5 Thinking da Chat01
Creato il: 25 maggio 2026
Risposto usando GPT-5.5 Thinking da Chat01
我要进行心脏超声特征提取实现冠心病预测的任务,现在用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)糖尿病病史指标:在预测模型中作为“冠心病等危症”对待的绝对核心临床多模态特征。用于大幅提高机器学习模型判定冠脉严重狭窄的预测敏感度。【与心衰高危因素部分重合,但在冠心病预测中具备决定性权重】
整体方向是对的,你现在从 EchoNet-Dynamic 的 LV blood-pool 分割出发,把“全局 EF/FAC 类指标”升级到“局部节段性室壁运动曲线”,这个转向非常适合冠心病任务。EchoNet-Dynamic 原始定位确实更偏向左室分割、EF 估计和 HFrEF 分类,而冠心病更依赖区域室壁运动异常、节段供血域聚合、应激前后变化和临床风险因子联合判断。(GitHub)
但你的汇报里有几处建议改一下:
A4C 单切面容积不要写得太像临床标准 LVEF。
A4C 单切面 Simpson 或旋转体积分可以作为 white-box proxy,但正式 LV 容积推荐通常基于 A4C + A2C 的 biplane Simpson,并且要避免心尖缩短导致容积低估。你可以把它写成“单平面估算容积 / LVEF Proxy”,多切面后再升级为“双平面或多平面联合估算容积”。(ASE)
“18 个标准解剖节段”建议改成“18 个切面投影节段”。
临床通用是 AHA 16/17 节段模型,17 节段模型还包含 apical cap,常用于灌注或跨模态比较。你现在的 A4C/A2C/A3C 每个切面 6 段很适合算法提特征,但它更准确的叫法是“18 个 view-segments / 投影节段”,后续需要映射回 AHA 16/17 节段和 LAD、LCx、RCA 供血域。(ASE)
冠心病不能只依赖静息收缩幅度。
静息 RWMA 对陈旧梗死、明显狭窄或冬眠心肌有价值,但稳定冠心病在静息状态可能没有明显异常。应激超声中,缺血反应的经典定义是“原本正常节段在负荷下出现新的室壁运动异常,或原本异常节段在负荷下进一步恶化”。所以如果你没有负荷/应激视频,建议表述为“冠心病风险预测 / 供血域受累提示”,不要写成“病变血管精准定位”的临床定论。(ScienceDirect)
灰度纹理部分可以保留,但要加质量控制。
超声灰度受增益、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训练过,你能提供给我也行),修改的代码通过代码窗口发送给我,不要直接生成文件,我接收不到。完成这个帧提取工作后,再为我提供绘制掩膜的工具安装使用指导。
"""
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"))
textfor 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()
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;
你的情况不建议继续用“第 0 帧 = ED、txt 里的 ES 毫秒数直接换算帧号”这条路线。更稳的方案是:
txt 只提供相位,不提供绝对时间。
比如你给的 txt 中 Total 最低谷在 319.54 ms,整段周期约 798.86 ms,所以 ES 相位约为 319.54 / 798.86 = 0.40。后续不再用“319.54 ms 对应多少帧”,而是用:
textES帧 = 选中的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()
运行方式:
bashpython dump_esed_frames_robust.py
只导出 ED/ES 汇总图。如果你还想像原脚本一样导出每个视频的全量帧:
bashpython dump_esed_frames_robust.py --dump_all
建议你后续人工复核时优先看 esed_keyframe_qc.csv 里这几列:cycle_score、ed_jitter、es_jitter、rr_method、es_phase。jitter 高的视频多半存在探头移动或切面漂移,医生画掩膜前最好单独确认。
我建议你先用 Label Studio,因为你当前任务是给医生画 ED/ES 两帧的 LV blood pool 或心肌轮廓,Label Studio 上手最快,支持图像语义分割和 brush/polygon 标注;官方文档也明确支持图像标注项目和 Brush 类分割标签。(Label Studio)
安装:
bashconda create -n echo_mask python=3.10 -y conda activate echo_mask pip install label-studio label-studio start
浏览器打开:
texthttp://localhost:8080
使用流程:
esed_keyframes_summary 文件夹里的 PNG。LV_BloodPoolLV_Myocardium推荐的 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 官方安装路线是 Docker Compose,本身面向图像、视频和分割标注任务。(CVAT Documentation)
安装:
bashgit clone https://github.com/cvat-ai/cvat cd cvat docker compose up -d
然后创建管理员账号:
bashdocker exec -it cvat_server bash -ic 'python3 ~/manage.py createsuperuser'
浏览器打开:
texthttp://localhost:8080
使用流程:
LV_BloodPool、LV_Myocardium。你的当前阶段我会建议这样排优先级:
第一阶段: 用上面的脚本稳定导出 ED/ES,并人工复核 50–100 例。
第二阶段: 用 Label Studio 让医生画 LV blood pool 掩膜。
第三阶段: 用这些自有 ED/ES 掩膜训练一个 A2C/A3C/A4C 联合 U-Net 或 nnU-Net。
第四阶段: 再反过来用模型自动分割全视频,提取你前面设计的局部节段收缩特征。