diff --git a/WORK_LOG.md b/WORK_LOG.md index 41bfc66..e6e3f64 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -4,7 +4,7 @@ ## 当前目标 -第 2 步「装配两个库(不含 CEUS)」已按任务书 01 实现,等监督者验收。之后是第 3 步 CEUS 定量或会诊口径讨论(`结构.md` 第七部分)。 +第 3 步 CEUS 定量,按任务书 02 分四个阶段做;第一阶段(底层和分割之前的算法)与 C14 修复已提交,下一个是第二阶段(MedSAM2 与后半段算法)。 ## 已完成 @@ -17,6 +17,8 @@ - 2026-10-16 任务书 01 装配两个库:delivery、library、answers、runs(快照)、build 五个模块,`scripts/build.py`、`scripts/scan_library.py`、`config/build.yaml`,91 条测试,`make check` 通过(6 条导入契约)。两轮独立审阅的会悄悄漏答案的意见已修(C11–C13 加严、泄露扫描 NFKC、关键图只收能保真重编码的 PNG、YAML 拒绝重复键)。真实 40 例:40 例中 34 例进库,跳过 6 例全为 C10(跨表不一致,与 `待定问题/04` 一致),复核清单 2 条,泄露扫描命中 0;构建号与提交号见提交说明和本次汇报。使用说明 `guides/装配两个库.md`。 +- 2026-10-16 任务书 02 第一阶段:`src/cadx/ceus/` 迁入配置(去掉全部默认值)、视频解码(按路径逐帧读)、分屏、绿框、提示帧、两半坐标换算;新写判断造影画面在哪一半(两半各选提示帧,恰好一半有才行,否则 `ContrastSideError`);坐标换算改为两个视图左上角之差,不再用锚点。`config/ceus.yaml` 只有 `paths`、`ceus` 两段。同一提交修了任务书 01 的关键图问题:不能保真重编码的关键图整例跳过,记 C14。`make check` 通过(348 条测试、7 条导入契约)。 + ## 未完成 - 2026-10-16 任务书 01 由执行者完成(提交 bceb01e),监督者验收通过:真实 40 例进库 34、跳过 6(C10 跨表不一致),独立脚本核对资料库内容与跳过名单一致,泄露扫描 0 命中。 @@ -37,8 +39,8 @@ ## 下一步 -- 监督者验收任务书 01。 -- 待负责人定:关键图格式不合要求时整次停下还是整例跳过;化名种子在 git 里,拿到种子可反推进库病例的病例号先后(专家拿不到种子);含「造影提示:」的那份超声描述所在病例被 C10 跳过,故不在复核清单里。 +- 任务书 02 第二阶段:MedSAM2 拷代码和权重、segment 模块、其余 11 个 ceus 模块迁移。 +- 待负责人定:化名种子在 git 里,拿到种子可反推进库病例的病例号先后(专家拿不到种子);含「造影提示:」的那份超声描述所在病例被 C10 跳过,故不在复核清单里。 - 向临床方要编码映射表(性别、诊断、症状、既往史、化验档位)。 - 把 `待定问题/04`(6 例跨表不一致)、`待定问题/06`(1 份超声描述中段的「造影提示:」)转给医生;06 定之前的构建不做正式会诊。 - 等临床方交付 40 例的医生标注工作簿(含造影剂到达时间和注射时间)。 diff --git a/config/ceus.yaml b/config/ceus.yaml new file mode 100644 index 0000000..cdc4eab --- /dev/null +++ b/config/ceus.yaml @@ -0,0 +1,155 @@ +# CEUS 定量的配置,给 scripts/ceus_quant.py 用。 +# +# 分三段:paths(位置)、segment(分割设置,任务书 02 第二阶段加)、ceus(算法参数)。 +# ceus 段的字段定义和校验在 src/cadx/ceus/_config.py;paths 段和最外层由 scripts/ceus_quant.py 定义。 +# 每个字段必填,可以没有的值写 null。 +# +# ceus 段的数值逐个照抄旧项目 config/ceus_roi_default.yaml,没有改动(待定问题/02:参数何时冻结)。 +# 注释里的出处: +# - 「出自旧项目」:旧 yaml 注释原样引用的来源。其中 findings 指旧项目 +# research-wiki/findings/2026-06-16-roi-tic-config-parameter-reference.md(伙伴参考组件 +# references/ROI/ 那套参数的原始口径),§2A 指「删键即静默退化、yaml 值与代码默认不同」的那批; +# design/00NN 指旧项目 research-wiki/design/;《临床口径契约》在 Linear,抄本可能过期。 +# - 「旧项目工程侧定的,无出处」:旧 yaml 没给来源的值。 + +paths: + delivery_dir: data/交付包_20260908_40例 # 交付包位置,相对仓库根目录 + runs_root: runs/ceus # 每次运行一个子目录,只追加不删 + work_root: work # 可删掉重算的中间文件(分割缓存) + +ceus: + config_version: "4" # 参数口径版本号,由人改。出自旧项目:Lognormal-v2 参数口径(design/0044) + + views: # 出自旧项目:findings §3.1 视图切分 + layout: horizontal_split # 屏幕左右各一半。出自旧项目 findings §3.1 + split_sample_frames: 25 # 找分屏线只用均匀抽的 25 帧。旧 debug 脚本写死的数,旧项目工程侧定的,无出处 + split_search_band: [0.35, 0.65] # 只在宽度的这一段里找分屏线。旧 detect_split_x 的参数默认值,旧项目工程侧定的,无出处 + + prompt_source: # 出自旧项目:findings §3.2 绿框检测 + type: color_polygon # 出自旧项目 findings §3.2 + hsv_lower: [35, 60, 60] # 出自旧项目 findings §3.2 + hsv_upper: [90, 255, 255] # 出自旧项目 findings §3.2 + ignore_regions: [[0.0, 0.0, 1.0, 0.0834]] # 抹掉顶部约 8.34% 的文字带。出自旧项目 findings §3.2 + min_area: 10000 # 外接框面积 w×h(findings §2D)。出自旧项目 findings §3.2 + + pan_source: # 紫圈检测(待定问题/08)。出自旧项目 design/0030 决策四;旧 yaml 注明全部是占位、待真实素材标定 + type: color_polygon # 旧项目工程侧定的,无出处 + hsv_lower: [125, 60, 60] # 占位,待标定。出自旧项目 design/0030 决策四 + hsv_upper: [165, 255, 255] # 占位,待标定。出自旧项目 design/0030 决策四 + ignore_regions: [[0.0, 0.0, 1.0, 0.0834]] # 占位,同绿框那条顶部文字带。出自旧项目 design/0030 决策四 + min_area: 2000 # 占位,待标定。出自旧项目 design/0030 决策四 + min_detected_frame_fraction: 0.9 # 占位,待标定。出自旧项目 design/0030 决策四 + + keyframes: # 提示帧选择(不是第 8 步的关键帧)。出自旧项目:findings §3.3 + change_metric: mask_iou # 出自旧项目 findings §3.3 + iou_threshold: 0.92 # 出自旧项目 findings §3.3 + min_gap_frames: 1 # 出自旧项目 findings §3.3 + + smoothing: # 出自旧项目:findings §3.6 轮廓分辨率与时间平滑 + contour_points: 64 # 出自旧项目 findings §3.6 + temporal_window: 15 # 须奇数。出自旧项目 findings §3.6 + + roi_shape: # 最终轮廓与绿框的融合(待定问题/01)。出自旧项目:findings §3.7 + source: prompt_prior # findings §2A:非代码默认 medsam2。出自旧项目 findings §3.7 + keyframe_shape_strength: 1.0 # 出自旧项目 findings §3.7 + propagation_shape_strength: 0.75 # 出自旧项目 findings §3.7 + min_area_ratio_to_prompt: 0.70 # 须 < max。出自旧项目 findings §3.7 + max_area_ratio_to_prompt: 1.25 # 出自旧项目 findings §3.7 + + interior_dark_region: # 暗区识别。出自旧项目:findings §3.8 + enabled: true # findings §2A:非代码默认 false。出自旧项目 findings §3.8 + analysis_view: source # findings §2A:非代码默认 target。出自旧项目 findings §3.8 + exclude_prompt_color: true # findings §2A:非代码默认 false。出自旧项目 findings §3.8 + threshold_method: auto_ensemble # findings §2A:非代码默认 otsu。出自旧项目 findings §3.8 + candidate_methods: [otsu, adaptive, percentile, kmeans, clahe_otsu] # 出自旧项目 findings §3.8 + threshold_offset: 0 # 出自旧项目 findings §3.8 + min_area: 300 # findings §2A:非代码默认 64。出自旧项目 findings §3.8 + min_area_ratio: 0.015 # findings §2A:非代码默认 0.0。出自旧项目 findings §3.8 + preserve_holes: true # 出自旧项目 findings §3.8 + fill_holes: false # 出自旧项目 findings §3.8 + morphology_kernel: 5 # 出自旧项目 findings §3.8 + fragment_penalty_weight: 0.08 # 出自旧项目 findings §3.8 + temporal_consistency_weight: 0.35 # 出自旧项目 findings §3.8 + contour_points: 64 # 出自旧项目 findings §3.8 + # 以下是各方法的子参数:参考 yaml 没列,取参考代码的默认值。出自旧项目 findings §2C、§3.8 + percentile: 45.0 # 出自旧项目 findings §2C + threshold: 96 # absolute 法的固定灰度阈值。出自旧项目 findings §2C + adaptive_block_size: 31 # 须奇数 ≥3。出自旧项目 findings §2C + adaptive_c: 5.0 # 出自旧项目 findings §2C + kmeans_max_iter: 30 # 出自旧项目 findings §2C + kmeans_attempts: 3 # 出自旧项目 findings §2C + clahe_clip_limit: 2.0 # 出自旧项目 findings §2C + clahe_tile_grid_size: 8 # 出自旧项目 findings §2C + + constraints: # 出自旧项目:findings §3.9 提示帧位置约束 + keyframe_position: + enabled: true # findings §2A:非代码默认 false。出自旧项目 findings §3.9 + mode: center_interpolation # 出自旧项目 findings §3.9 + strength: 1.0 # 截到 [0, 1]。出自旧项目 findings §3.9 + + tic_analysis: # 出自旧项目:findings §3.11 TIC 曲线(参考 yaml 整块不存在,取参考代码默认值) + brightness_metric: grayscale_mean # 出自旧项目 findings §3.11 + exclude_prompt_color: true # 出自旧项目 findings §3.11 + prompt_exclude_dilate: 3 # 出自旧项目 findings §3.11 + baseline: # 增强前基线。出自旧项目 design/0030 决策二 + preferred_duration_s: 2.0 # 出自旧项目:Linear《临床口径契约》 + minimum_duration_s: 1.0 # 出自旧项目:Linear《临床口径契约》 + stability_sd_multiplier: 3.0 # 临床口径里没有这个判据,旧项目工程侧占位,无出处 + reference_region: # 病灶外背景圆。出自旧项目 findings §3.11 + enabled: true # 出自旧项目 findings §3.11 + mode: auto_near_roi # 出自旧项目 findings §3.11 + radius_px: 24 # 出自旧项目 findings §3.11 + min_distance_px: 12 # 出自旧项目 findings §3.11 + nonblack_threshold: 12 # 出自旧项目 findings §3.11 + min_valid_fraction: 0.65 # 出自旧项目 findings §3.11 + center_xy: null # manual 模式必填。出自旧项目 findings §3.11 + fitting: # Lognormal 拟合。出自旧项目 design/0044:工程验收占位,代表性开发集标定后一次性锁定 + algorithm_version: lognormal-v2 # 出自旧项目 design/0044 + detect_duplicate_frames: true # 出自旧项目 design/0044 + candidate_window_s: 1.5 # 二阶 Savitzky-Golay 只做候选检测,窗口按秒。出自旧项目 design/0044 + candidate_long_window_s: 5.0 # 宏观团注候选,抑制逐帧掩膜与压缩波动。出自旧项目 design/0044 + candidate_polyorder: 2 # 出自旧项目 design/0044 + max_candidate_gap_s: 0.5 # 出自旧项目 design/0044 + duplicate_mean_absdiff_threshold: 0.25 # 8 位灰度的平均绝对差。出自旧项目 design/0044 + minimum_valid_samples: 20 # 出自旧项目 design/0044 + minimum_peak_distance_s: 1.0 # 出自旧项目 design/0044 + minimum_relative_peak_height: 0.10 # 出自旧项目 design/0044 + multi_peak_minimum_separation_s: 5.0 # 出自旧项目 design/0044 + multi_peak_minimum_relative_height: 0.50 # 出自旧项目 design/0044 + multi_peak_maximum_valley_fraction: 0.25 # 出自旧项目 design/0044 + sigma_min: 0.08 # 出自旧项目 design/0044 + sigma_max: 2.5 # 出自旧项目 design/0044 + robust_loss: soft_l1 # 出自旧项目 design/0044 + max_nfev: 4000 # 出自旧项目 design/0044 + minimum_r2: 0.35 # 出自旧项目 design/0044 + maximum_nrmse: 0.45 # 出自旧项目 design/0044 + maximum_residual_noise_ratio: 2.0 # 残差不高于基线噪声同量级时允许低 R²。出自旧项目 design/0044 + boundary_margin_s: 0.25 # 出自旧项目 design/0044 + estimated_baseline_percentile: 15.0 # 无稳定开头时的有界基线先验。出自旧项目 design/0044 + estimated_baseline_lookback_s: 10.0 # 允许 t0 位于视频起点前,但截图不外推。出自旧项目 design/0044 + maximum_perf_area_step_fraction: 0.15 # 相邻帧去暗区面积比例最大变化。出自旧项目 design/0044 + parameter_boundary_fraction: 0.001 # 形状参数贴边判据占允许区间的比例。出自旧项目 design/0044 + anchor_frames: # 7 张关键帧定位。出自旧项目 design/0040 决策二 + # 下面这批数旧项目注明镜像自 Linear《临床口径契约》(2026-08-25 版,同步日期 2026-09-01), + # 动任何一个之前先去那边核对。各容差窗口的出处在第二阶段迁移关键帧模块时逐个再核对。 + arrival: # 到达判据,门槛取两个候选的高者。出自旧项目:《临床口径契约》5.2 + baseline_sd_multiplier: 3.0 # 候选一:基线抖动 ×3.0。出自旧项目:《临床口径契约》5.2 + relative_peak_threshold: 0.10 # 候选二:峰高 ×0.10。出自旧项目:《临床口径契约》5.2 + persistence_s: 0.5 # 越线后连续保持 0.5 秒。出自旧项目:《临床口径契约》(08-23 确认) + # 主峰突出度门槛:基线抖动 ×3。与到达判据那个 3.0 是两个独立的数,改一个不连带改另一个。 + # 出自旧项目:《临床口径契约》第六节 + peak_prominence_baseline_sd_multiplier: 3.0 + baseline_frame_backoff_s: 0.3 # B0 锚点 = 到达时刻再往前 0.3 秒。出自旧项目:《临床口径契约》5.11 + d1_window_after_s: 0.5 # D1 窗 [锚点, 锚点+0.5]。出自旧项目:《临床口径契约》第 12 题 + d2_window_s: 0.3 # D2 窗 ±0.3 秒。出自旧项目:《临床口径契约》第 12 题 + d3_window_s: 0.5 # D3 窗 ±0.5 秒。出自旧项目:《临床口径契约》第 12 题 + # D4 窗 ±0.5 秒:契约空着、第 12 题委托工程侧。旧项目工程侧定,理由是 D4 与 D2/D3 同属曲线 + # 形状锚点,洗出段比洗入段平缓、导数极值定位误差更大,取 D2/D3 中较宽的一档。无外部出处 + d4_window_s: 0.5 + b0_window_s: 0.5 # B0 窗 ±0.5 秒:契约空着,旧项目工程侧取与 D4 同档,无外部出处 + late_phase: + preferred_time_s: 60.0 # F2 锚点:推注后 60 秒。出自旧项目:《临床口径契约》 + tolerance_s: 2.0 # F2 容差 ±2 秒。出自旧项目:《临床口径契约》第 12 题 + # F1 锚点:推注后 20 秒。出自旧项目:《临床口径契约》(08-16 口头定)。 + # F1 没有容差键:旧项目工程侧暂定「无上限,取最近的一帧」,无出处 + f1_anchor_s: 20.0 diff --git a/pyproject.toml b/pyproject.toml index 5aae5d8..a6e3477 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,8 @@ version = "0.0.0" description = "胰腺囊性肿瘤多模态会诊诊断的研究代码" requires-python = ">=3.12" dependencies = [ + "numpy>=2.5.4", + "opencv-python-headless>=5.0.0.93", "openpyxl>=3.1.5", "pillow>=12.3.0", "pydantic>=2.14.1", @@ -89,6 +91,16 @@ forbidden_modules = [ "cadx.consult", "cadx.evaluate", "cadx.verdicts", ] +[[tool.importlinter.contracts]] +# 结构.md 第三部分依赖表:ceus 是纯计算,不引用其他 cadx 模块(含 segment,分割结果由脚本传进来)。 +name = "ceus 不引用其他 cadx 模块" +type = "forbidden" +source_modules = ["cadx.ceus"] +forbidden_modules = [ + "cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", + "cadx.segment", "cadx.llm", "cadx.ledger", "cadx.consult", "cadx.evaluate", "cadx.verdicts", +] + [[tool.importlinter.contracts]] # 结构.md 第三部分依赖表:build 只依赖 delivery、library、answers、runs(ceus 落地时加入)。 name = "build 只引用 delivery、library、answers、runs" diff --git a/research-wiki/explanation/结构.md b/research-wiki/explanation/结构.md index a1a71c4..6e4ed84 100644 --- a/research-wiki/explanation/结构.md +++ b/research-wiki/explanation/结构.md @@ -138,7 +138,7 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu ### 几条边界 - **专家能看见什么,只在 build 一处决定。** build 按白名单把每路允许的内容写进 `library/`,此外任何模块都不挑选给专家的材料。consult 只拿到某一次构建的路径,经 library 按路读出;它不依赖 delivery、answers、build,读原始资料和答案的代码对它来说根本不存在。报告的「印象 / 结论」段(医生写的诊断倾向)由 delivery 认出、由 build 剔除,`library/` 里只有「所见」描述。 -- **交付资料的检查不过,整例跳过。** delivery 逐例检查(编号、文件、取值、跨表是否一致;检查项 C01–C13 的速查表见 `../guides/装配两个库.md`,规则以 `src/cadx/delivery/_cases.py`、`_text.py` 为准),build 把没过的病例整例跳过:资料库和答案库的病理诊断里都没有它,答案库的跳过清单写明它没过哪一项、涉及哪个文件哪一列。一例遇到第一项不过就不再往下查,清单上每例只记一项。整张表读不了时整次构建停下。规则见 `AGENTS.md`「错误怎么处理」。 +- **交付资料的检查不过,整例跳过。** delivery 逐例检查(编号、文件、取值、跨表是否一致;检查项 C01–C14 的速查表见 `../guides/装配两个库.md`,规则以 `src/cadx/delivery/_cases.py`、`_text.py` 为准),build 把没过的病例整例跳过:资料库和答案库的病理诊断里都没有它,答案库的跳过清单写明它没过哪一项、涉及哪个文件哪一列。一例遇到第一项不过就不再往下查,清单上每例只记一项。整张表读不了时整次构建停下。规则见 `AGENTS.md`「错误怎么处理」。 - **三路互相隔开。** 每个专家的提示词只用自己那一路的对象拼。consult 里拼某一路提示词的函数只接收那一路的对象,不接收整个病例。这是研究设计本身:模拟各科医生各看各的检查结果,再在会诊时交换意见。会诊时一路能看到另一路的什么,由 consult 的会诊流程决定,在会诊的任务书里定。 - **我们自己调模型只有一个入口:consult 的「调一次模型」。** 它按固定顺序做五步:把完整请求存进账本的大块存储、写「调用开始」、经 llm 调用、把完整回复存进大块存储、写「调用结束」。llm 只管调用,不写账本,所以任何一次模型调用都绕不开登记。中断后重启时,从账本找出开始了没结束的调用,记为「状态未知」,不重做已有结果的调用。做重复运行(同一病例、同一专家调多次,测一致性)时,每一次都恰好登记一次。 - **CEUS 算法不知道分割模型是谁,也不管中间文件放哪。** ceus 接收视频路径、参数和分割结果,交出曲线、拟合、关键帧和指标;中间文件写在哪、按什么复用,由 `scripts/ceus_quant.py` 决定(目前只有它一个使用者,出现第二个使用者再收进模块)。segment 只管「给提示、出逐帧掩码」,不认识分屏和绿框。 @@ -263,11 +263,12 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0 文件格式:数组用 npz(读取时 `allow_pickle=False`,pickle 读取时会执行任意代码),每例的标量和摘要用 json,跨病例的表用 csv,图用 png。缺失值写 null,不写 NaN 或 0:0 是合法的测量值,NaN 在 json 里不合法。 -导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 6 条),没有的保护不写: +导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 7 条),没有的保护不写: - `cadx` 不引用 `torch`、`sam2`。 - delivery、library、answers、runs 互不引用。 - delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。 +- ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。 - build 不引用 delivery、library、answers、runs 以外的 `cadx` 模块(ceus 落地时放行)。 - 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。 - 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。 @@ -337,7 +338,8 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 ### library(`src/cadx/library/`) -- `write_library(build_dir, build_id, git_commit, git_clean, config_sha256, codes_decoded, uncompared, ceus_source, cases)`:把一次构建的各例三路内容写进一个已存在的空目录,关键图去掉附加数据重新编码,最后写 `manifest.json`。 +- `write_library(build_dir, build_id, git_commit, git_clean, config_sha256, codes_decoded, uncompared, ceus_source, cases)`:把一次构建的各例三路内容写进一个已存在的空目录,关键图去掉附加数据重新编码,最后写 `manifest.json`。关键图不能保真重编码时报错停下(说明 build 漏了 C14)。 +- `check_key_image(path)`:关键图能逐像素保真重编码时返回 None,不能时(不是 PNG、位深或颜色类型不行、带 tRNS 透明色)返回原因。 - `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述。 - `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。 - `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。 @@ -363,9 +365,19 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 - `make_build_id(now, git)`:构建号 `YYYYMMDD-HHMMSS-`,工作区不干净时末尾加 `-dirty`。 - `write_snapshot(snapshot_dir, build_id, git, started_at, finished_at, uv_lock, configs)`:新建快照目录,复制配置原文并写 `snapshot.json`(构建号、git 状态、Python 版本、`uv.lock` 哈希、起止时间、各配置的哈希)。 +### ceus(`src/cadx/ceus/`,目前只有第 1、2 步和坐标换算) + +- `CeusConfig` 及各段的配置类:`config/ceus.yaml` 里 `ceus` 段的字段定义(pydantic,字段必填、不许多写、读入后不可改);`paths` 段由 `scripts/ceus_quant.py` 定义。 +- `VideoFile(path)`:按路径用 cv2 读视频:`probe()` 给 fps、帧数、宽高;`iter_frames()` 每次从第 0 帧逐帧读;`frame_times_s()` 给逐帧显示时间戳。 +- `split_views(frames, views_cfg)`:第 1 步,在均匀抽的帧上找分屏线,交出分屏线和左右两半的 `ViewRect`;找不到可信的分屏线抛 `SplitDetectionError`。 +- `locate_contrast_side(frames, left_view, right_view, prompt_cfg, keyframes_cfg)`:第 2 步,两半各自找绿框、选提示帧,恰好一半有就定为造影画面,交出 `ContrastSide`(哪一半、造影视图、另一半视图、提示帧);两半都有或都没有抛 `ContrastSideError`。 +- `view_offset`、`map_source_mask_to_target`、`target_polygons_to_source`:两半之间的坐标换算,平移量是两个视图左上角之差。 +- `CeusCaseError`:只落在这一例上的失败,带失败的步号和原因;`SplitDetectionError`、`ContrastSideError` 是它的子类。脚本据此把这一例记进 `failed.csv`、接着跑下一例。 +- 其余导出(`ViewRect` 与几何函数、`extract_color_prompt`、`select_prompt_frames` 等)是各步的组成部分,供第二阶段的模块和测试用。 + ### build(`src/cadx/build/`) -- `run_build(config_path, repo_root, now)`:完整做一次构建:读配置和映射表、读交付包、取化名、先写进 `.partial` 目录再改名,交出 `BuildSummary`。 +- `run_build(config_path, repo_root, now)`:完整做一次构建:读配置和映射表、读交付包、对每例做 C14(`check_key_image` 返回原因的整例跳过,记进跳过清单)、取化名、先写进 `.partial` 目录再改名,交出 `BuildSummary`。 - `BuildSummary`:供终端打印的计数:构建号、两个库的路径、病例总数、进库数、跳过数、认不出编号的行数、按检查编号分的跳过数、复核清单条数、跳过清单说明句。 - `BuildConfig`:`build.yaml` 的字段定义(pydantic,字段必填、不许多写、读入后不可改)。 - `load_build_config(path, repo_root)`:读并校验 `build.yaml`,相对路径按仓库根目录解析。 diff --git a/research-wiki/guides/装配两个库.md b/research-wiki/guides/装配两个库.md index cd40a71..ed04add 100644 --- a/research-wiki/guides/装配两个库.md +++ b/research-wiki/guides/装配两个库.md @@ -135,7 +135,7 @@ CSV 用带 BOM 的 UTF-8(文件开头有一个不可见的标记),医生 两个库先写进 `<根目录>/<构建号>.partial/`,全部写完、清单算完后再改名成 `<构建号>/`:先改答案库,最后改资料库。所以: - **资料库的 `<构建号>/` 出现,才算这次构建完成。** 会诊只读资料库,读取函数也拒绝名字以 `.partial` 结尾的目录,没写完的构建不会被用上。 -- 看到 `.partial` 目录,说明那次构建中途出错。目前已知的一种是某张关键图的格式(位深、颜色类型、透明色)不能逐像素保真重编码:这本是只落在一例上的问题,但现在的代码让整次构建停下,而不是跳过这一例。它不会被自动删除,留着给人查原因;查完由人决定删不删。 +- 看到 `.partial` 目录,说明那次构建中途出错。它不会被自动删除,留着给人查原因;查完由人决定删不删。 - 只有答案库的 `<构建号>/`、没有同号的资料库,同样是没完成的构建。 - 配置、映射表或整张表的检查(T1–T5)不过时,构建在建目录之前就停下,不留任何目录。 - 目标目录已经存在时报错,不覆盖。构建号带到秒,正常不会重名。 @@ -147,7 +147,7 @@ CSV 用带 BOM 的 UTF-8(文件开头有一个不可见的标记),医生 交付资料里的问题分两种(`AGENTS.md`「错误怎么处理」): - **整张表的问题**(T1–T5):一例也读不出来,整次构建停下,报错信息以检查编号开头,写明哪个文件哪一项不符。 -- **只落在某一个病例上的问题**(C01–C13):跳过这一整例,接着处理下一例,在 `skipped.csv` 里记一行。这一例不进资料库,也不进答案库的 `aliases.csv`、`diagnoses.csv`。 +- **只落在某一个病例上的问题**(C01–C14):跳过这一整例,接着处理下一例,在 `skipped.csv` 里记一行。这一例不进资料库,也不进答案库的 `aliases.csv`、`diagnoses.csv`。 先对三张表的每一行和每个病例目录做 C01(编号认不认得出);认得出的,再逐例按 C02 到 C13 的顺序检查,**遇到第一项没过就停下**,清单上每例只记这一项(C01 记的是认不出的那一行,不算某一例,见下文)。后面的检查没做过,不代表没问题:医生改好这一项后,重新构建时它可能卡在后面某一项。这句说明写在答案库 `manifest.json` 的 `skipped_note` 里,终端也会打印;CSV 文件开头不能加注释行,所以没写在 `skipped.csv` 里。为什么整例跳过而不是停下整次构建:负责人 2026-10-16「都是遇到整个病例跳过,让医生核对去吧」。评价时要报告跳过了几例、各因为什么。 @@ -156,7 +156,7 @@ CSV 用带 BOM 的 UTF-8(文件开头有一个不可见的标记),医生 | 列 | 含义 | | --- | --- | | 病例 | 三位病例号。C01(编号认不出)的行不知道是哪个病人,写成「表的路径 第 N 行」(N 是 Excel 行号,第 1 行是表头);病例目录名不在 1–999 之间时写成「病例目录 目录名」 | -| 检查编号 | C01–C13 | +| 检查编号 | C01–C14 | | 检查说明 | 这项检查的一句话说明 | | 文件 | 涉及的文件,相对交付包根目录,多个用「; 」隔开 | | 列 | 涉及的列,多个用「; 」隔开;不涉及具体列(如目录里的文件不对)时为空 | @@ -166,7 +166,7 @@ C01 的行不计入病例数,也不计入终端的「跳过病例」,单独 ### 检查编号速查 -规则细节以代码为准:T1–T4 在 `src/cadx/delivery/_tables.py`,T5 在 `src/cadx/build/_config.py`,C01–C10 在 `src/cadx/delivery/_cases.py`,C11–C13 的词表和正则在 `src/cadx/delivery/_text.py`,表名、工作表名、表头在 `src/cadx/delivery/_shape.py`。每条规则为什么这样定见任务书 `../任务书/01-装配两个库.md` 第二部分。 +规则细节以代码为准:T1–T4 在 `src/cadx/delivery/_tables.py`,T5 在 `src/cadx/build/_config.py`,C01–C10 在 `src/cadx/delivery/_cases.py`,C11–C13 的词表和正则在 `src/cadx/delivery/_text.py`,C14 由 build 调 library 的 `check_key_image`(`src/cadx/library/_write.py`)在写库前做,表名、工作表名、表头在 `src/cadx/delivery/_shape.py`。每条规则为什么这样定见任务书 `../任务书/01-装配两个库.md` 第二部分。 | 编号 | 查什么 | 不过时 | | --- | --- | --- | @@ -188,6 +188,7 @@ C01 的行不计入病例数,也不计入终端的「跳过病例」,单独 | C11 | 报告文字里没有结论段标题:段首(全文开头或换行之后)出现「印象」「结论」「提示」等关键词加冒号 | 跳过这一例 | | C12 | 报告文字里没有亚型名(缩写、中文名及其常见变体) | 跳过这一例 | | C13 | 报告文字里没有人口学信息:没有「数字岁」,没有「男」「女」两个字 | 跳过这一例 | +| C14 | 关键图能原样重编码:是 PNG,位深和颜色类型能逐像素保真(8 位灰度、RGB、灰度加透明通道、RGBA,或 1 位灰度),不带 tRNS 透明色;否则重编码会悄悄降位或丢透明。涉及值写不过的原因 | 跳过这一例 | 几点说明: diff --git a/src/cadx/build/_assemble.py b/src/cadx/build/_assemble.py index d95dac7..6b39d86 100644 --- a/src/cadx/build/_assemble.py +++ b/src/cadx/build/_assemble.py @@ -4,6 +4,7 @@ """ import hashlib +import json import random import string from collections import Counter @@ -20,10 +21,11 @@ from cadx.delivery import ( SKIPPED_NOTE, CaseRecord, CodeMaps, + SkipRecord, read_delivery, review_sentences, ) -from cadx.library import LibraryCase, write_library +from cadx.library import LibraryCase, check_key_image, write_library from cadx.runs import make_build_id, read_git_state, write_snapshot ALIAS_ALPHABET = string.ascii_lowercase + string.digits @@ -31,6 +33,9 @@ ALIAS_LENGTH = 4 # 化名里出现亚型缩写会被泄露扫描(不分大小写)误报,抽到就重抽。 _ALIAS_BANNED = ("ipmn", "spn", "mcn", "scn") +C14 = "C14" +C14_DESCRIPTION = "关键图能原样重编码" + @dataclass(frozen=True) class BuildSummary: @@ -75,6 +80,30 @@ def _clinical_fields( } +def _check_key_images( + cases: Sequence[CaseRecord], delivery_dir: Path +) -> tuple[list[CaseRecord], list[SkipRecord]]: + """C14:关键图不能逐像素保真重编码的病例整例跳过,原因写进涉及值。""" + kept: list[CaseRecord] = [] + skipped: list[SkipRecord] = [] + for case in cases: + reason = check_key_image(case.key_image) + if reason is None: + kept.append(case) + continue + skipped.append( + SkipRecord( + case=case.case_no, + check=C14, + description=C14_DESCRIPTION, + files=(case.key_image.relative_to(delivery_dir).as_posix(),), + columns=(), + value_json=json.dumps(reason, ensure_ascii=False), + ) + ) + return kept, skipped + + def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -91,6 +120,8 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary config = load_build_config(config_path, repo_root) code_maps = load_code_maps(config.code_maps) if config.code_maps is not None else None delivery = read_delivery(config.delivery_dir, code_maps) + passed, key_image_skips = _check_key_images(delivery.cases, config.delivery_dir) + skipped = [*delivery.skipped, *key_image_skips] git = read_git_state(repo_root) build_id = make_build_id(now, git) @@ -104,8 +135,8 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary for partial in (library_partial, answers_partial): partial.mkdir(parents=True, exist_ok=False) - aliases = make_aliases([case.case_no for case in delivery.cases], config.alias_seed) - by_alias = sorted(delivery.cases, key=lambda case: aliases[case.case_no]) + aliases = make_aliases([case.case_no for case in passed], config.alias_seed) + by_alias = sorted(passed, key=lambda case: aliases[case.case_no]) configs = {"build.yaml": config_path} if config.code_maps is not None: @@ -152,7 +183,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary skipped_note=SKIPPED_NOTE, aliases=[ AliasRow(alias=aliases[c.case_no], case_no=c.case_no, video_id=c.video_id) - for c in delivery.cases + for c in passed ], diagnoses=[ DiagnosisRow( @@ -163,7 +194,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary mr_code=c.diagnosis.mr, name=None if code_maps is None else code_maps["Diagnosis"][c.diagnosis.clinical], ) - for c in delivery.cases + for c in passed ], skipped=[ SkippedRow( @@ -174,7 +205,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary columns=s.columns, value_json=s.value_json, ) - for s in delivery.skipped + for s in skipped ], reviews=reviews, ) @@ -183,14 +214,14 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary _rename(answers_partial, answers_dir) _rename(library_partial, library_dir) - by_check = Counter(s.check for s in delivery.skipped) + by_check = Counter(s.check for s in skipped) return BuildSummary( build_id=build_id, library_dir=library_dir, answers_dir=answers_dir, total_cases=delivery.total_cases, - passed_cases=len(delivery.cases), - skipped_cases=len(delivery.skipped) - delivery.unrecognized_rows, + passed_cases=len(passed), + skipped_cases=len(skipped) - delivery.unrecognized_rows, unrecognized_rows=delivery.unrecognized_rows, skipped_by_check=dict(sorted(by_check.items())), review_rows=len(reviews), diff --git a/src/cadx/ceus/__init__.py b/src/cadx/ceus/__init__.py new file mode 100644 index 0000000..2028972 --- /dev/null +++ b/src/cadx/ceus/__init__.py @@ -0,0 +1,113 @@ +"""ceus:CEUS 定量的全部算法和参数,纯计算,不引用其他 cadx 模块。 + +目前有第 1、2 步(视频解码、分屏、找绿框、选提示帧、判断造影画面在哪一半)和两半之间的坐标换算; +第 3 步以后随任务书 02 第二阶段加入。 +某一例不满足算法前提时抛 `CeusCaseError`(带失败的步号和原因)。 +""" + +from cadx.ceus._config import ( + AnchorArrivalConfig, + AnchorFramesConfig, + AnchorLatePhaseConfig, + CeusConfig, + ConstraintsConfig, + InteriorDarkRegionConfig, + KeyframePositionConstraint, + KeyframesConfig, + PanSourceConfig, + PromptSourceConfig, + ReferenceRegionConfig, + RoiShapeConfig, + SmoothingConfig, + TicAnalysisConfig, + TicBaselineConfig, + TicFittingConfig, + ViewsConfig, +) +from cadx.ceus._contrast_side import ContrastSide, ContrastSideError, locate_contrast_side +from cadx.ceus._errors import CeusCaseError +from cadx.ceus._geometry import ( + ViewRect, + crop_frame, + local_rect_within, + mask_iou, + odd_window, + optional_positive_int, + polygon_area, + polygon_center, + polygon_to_mask, + polygons_to_mask, + rect_from_values, + resample_closed_contour, + triangular_smooth, +) +from cadx.ceus._green_prompt import ( + ColorPrompt, + PromptObservation, + collect_prompt_observations, + extract_color_prompt, + prompt_color_mask, +) +from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source, view_offset +from cadx.ceus._prompt_frames import PromptFrame, select_prompt_frames +from cadx.ceus._split_screen import ( + SplitDetectionError, + detect_split_x, + sample_frames_for_split, + split_views, + view_rect, +) +from cadx.ceus._video import VideoFile + +__all__ = [ + "AnchorArrivalConfig", + "AnchorFramesConfig", + "AnchorLatePhaseConfig", + "CeusCaseError", + "CeusConfig", + "ColorPrompt", + "ConstraintsConfig", + "ContrastSide", + "ContrastSideError", + "InteriorDarkRegionConfig", + "KeyframePositionConstraint", + "KeyframesConfig", + "PanSourceConfig", + "PromptFrame", + "PromptObservation", + "PromptSourceConfig", + "ReferenceRegionConfig", + "RoiShapeConfig", + "SmoothingConfig", + "SplitDetectionError", + "TicAnalysisConfig", + "TicBaselineConfig", + "TicFittingConfig", + "VideoFile", + "ViewRect", + "ViewsConfig", + "collect_prompt_observations", + "crop_frame", + "detect_split_x", + "extract_color_prompt", + "local_rect_within", + "locate_contrast_side", + "map_source_mask_to_target", + "mask_iou", + "odd_window", + "optional_positive_int", + "polygon_area", + "polygon_center", + "polygon_to_mask", + "polygons_to_mask", + "prompt_color_mask", + "rect_from_values", + "resample_closed_contour", + "sample_frames_for_split", + "select_prompt_frames", + "split_views", + "target_polygons_to_source", + "triangular_smooth", + "view_offset", + "view_rect", +] diff --git a/src/cadx/ceus/_config.py b/src/cadx/ceus/_config.py new file mode 100644 index 0000000..5713fb0 --- /dev/null +++ b/src/cadx/ceus/_config.py @@ -0,0 +1,533 @@ +"""CEUS 定量的参数:`config/ceus.yaml` 里 `ceus` 段的字段定义和校验。 + +照搬旧项目 `research_config.py` 的配置类,去掉全部默认值:每个字段必填,可以没有的值显式写 +null;多写或拼错的键报错;读入后不能修改(`AGENTS.md`「不设默认值」)。各参数的取值与出处写在 +`config/ceus.yaml` 的注释里,这里只写字段的含义和校验规则。 + +旧配置里以下字段删掉了,不留着不用: +- `views.source_view` / `views.target_view`:造影画面在哪一半改由两半各自找绿框决定(`_views.py`); +- `views.effective_region`:裁画面黑边只有旧 `service.py` 用,40 例结果走的 debug 路径不裁; +- `mapping`(含锚点):两半之间的坐标换算改成两个视图矩形左上角之差(`_mapping.py`), + 不再需要按某一种视频宽度写死的锚点。 + +`paths` 段和整份文件最外层的模型由 `scripts/ceus_quant.py` 定义,不在这里。 +""" + +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +_STRICT = ConfigDict(strict=True, extra="forbid", frozen=True) + + +def _require_positive_odd(value: int, field_name: str) -> int: + """正奇数校验:时间平滑窗口、自适应阈值块大小都要求奇数(窗口要有中心)。""" + if value < 1: + raise ValueError(f"{field_name} 必须为正整数,收到 {value}") + if value % 2 == 0: + raise ValueError(f"{field_name} 必须为奇数,收到 {value}") + return value + + +class ViewsConfig(BaseModel): + """分屏:超声机录造影时屏幕左右各一半,目前只支持左右分屏。 + + 找分屏线只用均匀抽的 `split_sample_frames` 帧,在宽度的 `split_search_band` 比例区间里找。 + 这两项在旧代码里是写死的数(旧 debug 脚本的 25、旧 `detect_split_x` 参数默认的 (0.35, 0.65)), + 这里改成配置,值不变。 + """ + + model_config = _STRICT + + layout: Literal["horizontal_split"] + split_sample_frames: int + split_search_band: list[float] + + @field_validator("split_sample_frames") + @classmethod + def _sample_frames_positive(cls, value: int) -> int: + if value < 1: + raise ValueError(f"views.split_sample_frames 必须 ≥1,收到 {value}") + return value + + @field_validator("split_search_band") + @classmethod + def _band_pair(cls, value: list[float]) -> list[float]: + if len(value) != 2: + raise ValueError(f"views.split_search_band 必须是 [lo, hi] 两个数,收到 {value}") + return value + + +class PromptSourceConfig(BaseModel): + """绿框的 HSV 颜色检测。`min_area` 是候选外接框面积 w×h,不是轮廓面积。""" + + model_config = _STRICT + + type: Literal["color_polygon"] + hsv_lower: list[int] + hsv_upper: list[int] + ignore_regions: list[list[float]] + min_area: int + + +class PanSourceConfig(BaseModel): + """紫圈的 HSV 颜色检测:医生用紫色轮廓圈的一块正常胰腺,作对照。 + + 和绿框是同一类检测器,只是换一组颜色范围。`min_detected_frame_fraction` 是这一组独有的: + 紫圈每一帧都在,整段视频里检出的帧占比偏低,说明颜色范围没标定对,对照区整段不可用。 + """ + + model_config = _STRICT + + type: Literal["color_polygon"] + hsv_lower: list[int] + hsv_upper: list[int] + ignore_regions: list[list[float]] + min_area: int + min_detected_frame_fraction: float + + @field_validator("hsv_lower", "hsv_upper") + @classmethod + def _hsv_triple(cls, value: list[int]) -> list[int]: + """HSV 阈值须是三个分量,否则要到 `cv2.inRange` 里才报错,离配置很远。""" + if len(value) != 3: + raise ValueError(f"pan_source HSV 阈值必须是 3 个分量,收到 {value}") + return value + + @field_validator("min_area") + @classmethod + def _min_area_positive(cls, value: int) -> int: + """min_area 须 ≥1。""" + if value < 1: + raise ValueError(f"pan_source.min_area 必须 ≥1,收到 {value}") + return value + + @field_validator("min_detected_frame_fraction") + @classmethod + def _fraction_within_bounds(cls, value: float) -> float: + """占比须在 (0, 1]:取 0 这道门形同虚设,越界则永远判不出有效。""" + if not 0.0 < value <= 1.0: + raise ValueError(f"pan_source.min_detected_frame_fraction 必须 ∈ (0, 1],收到 {value}") + return value + + @model_validator(mode="after") + def _hsv_bounds_ordered(self) -> Self: + """逐分量上界 ≥ 下界。写反了 `cv2.inRange` 不报错,只会恒返回空掩膜。""" + pairs = zip(self.hsv_lower, self.hsv_upper, strict=True) + for index, (low, high) in enumerate(pairs): + if high < low: + raise ValueError( + "pan_source.hsv_upper 每一分量都必须 ≥ hsv_lower," + f"第 {index} 个分量收到 lower={low}, upper={high}" + ) + return self + + +class KeyframesConfig(BaseModel): + """提示帧的选择:绿框形状相对上一个提示帧的 IoU 跌破阈值就记一帧新的。 + + 键名沿用旧配置的 `keyframes`;它选的是给分割模型的提示帧,不是第 8 步那 7 张关键帧。 + """ + + model_config = _STRICT + + change_metric: Literal["mask_iou"] + iou_threshold: float + min_gap_frames: int + + +class SmoothingConfig(BaseModel): + """病灶轮廓的重采样点数和逐顶点时间平滑窗口(须正奇数)。""" + + model_config = _STRICT + + contour_points: int + temporal_window: int + + @field_validator("temporal_window") + @classmethod + def _temporal_window_odd(cls, value: int) -> int: + """三角平滑窗口须正奇数,偶数窗口没有中心。""" + return _require_positive_odd(value, "smoothing.temporal_window") + + @field_validator("contour_points") + @classmethod + def _contour_points_positive(cls, value: int) -> int: + """少于 3 个点围不成多边形。""" + if value < 3: + raise ValueError(f"smoothing.contour_points 必须 ≥3,收到 {value}") + return value + + +class RoiShapeConfig(BaseModel): + """最终病灶轮廓与绿框的融合(`待定问题/01`)。两个强度截到 [0, 1],面积比须有序。""" + + model_config = _STRICT + + source: str + keyframe_shape_strength: float + propagation_shape_strength: float + min_area_ratio_to_prompt: float + max_area_ratio_to_prompt: float + + @field_validator("keyframe_shape_strength", "propagation_shape_strength") + @classmethod + def _strength_in_unit_interval(cls, value: float) -> float: + """混合权重截到 [0, 1]。""" + return min(1.0, max(0.0, value)) + + @model_validator(mode="after") + def _area_ratio_ordered(self) -> Self: + """面积比下限须小于上限。""" + if self.min_area_ratio_to_prompt >= self.max_area_ratio_to_prompt: + raise ValueError( + "roi_shape.min_area_ratio_to_prompt 必须 < max_area_ratio_to_prompt," + f"收到 min={self.min_area_ratio_to_prompt}, " + f"max={self.max_area_ratio_to_prompt}" + ) + return self + + +class InteriorDarkRegionConfig(BaseModel): + """暗区(病灶里不进造影剂、一直发黑的囊液部分)的识别:方法选择、评分和各方法的子参数。""" + + model_config = _STRICT + + enabled: bool + analysis_view: Literal["source", "target"] + exclude_prompt_color: bool + threshold_method: Literal[ + "auto_ensemble", + "otsu", + "percentile", + "absolute", + "adaptive", + "kmeans", + "clahe_otsu", + ] + candidate_methods: list[ + Literal["otsu", "adaptive", "percentile", "kmeans", "clahe_otsu", "absolute"] + ] + threshold_offset: int + min_area: int + min_area_ratio: float + preserve_holes: bool + fill_holes: bool + morphology_kernel: int + fragment_penalty_weight: float + temporal_consistency_weight: float + contour_points: int + percentile: float + threshold: int # absolute 法的固定灰度阈值 + adaptive_block_size: int + adaptive_c: float + kmeans_max_iter: int + kmeans_attempts: int + clahe_clip_limit: float + clahe_tile_grid_size: int + + @field_validator("adaptive_block_size") + @classmethod + def _adaptive_block_size_odd(cls, value: int) -> int: + """自适应阈值的块大小须正奇数且 ≥3(OpenCV 的要求)。""" + validated = _require_positive_odd(value, "interior_dark_region.adaptive_block_size") + if validated < 3: + raise ValueError(f"interior_dark_region.adaptive_block_size 必须 ≥3,收到 {validated}") + return validated + + @field_validator("percentile") + @classmethod + def _percentile_within_bounds(cls, value: float) -> float: + """百分位须在 [0, 100]。""" + if not 0.0 <= value <= 100.0: + raise ValueError(f"interior_dark_region.percentile 必须 ∈ [0, 100],收到 {value}") + return value + + +class KeyframePositionConstraint(BaseModel): + """提示帧上病灶轮廓的位置约束:把轮廓中心平移向绿框中心。strength 截到 [0, 1]。""" + + model_config = _STRICT + + enabled: bool + mode: Literal["center_interpolation"] + strength: float + + @field_validator("strength") + @classmethod + def _strength_in_unit_interval(cls, value: float) -> float: + """截到 [0, 1],过冲会把轮廓推出画面。""" + return min(1.0, max(0.0, value)) + + +class ConstraintsConfig(BaseModel): + """约束段。""" + + model_config = _STRICT + + keyframe_position: KeyframePositionConstraint + + +class ReferenceRegionConfig(BaseModel): + """病灶外背景圆的选取。manual 模式必须给中心坐标,auto_near_roi 时为 null。""" + + model_config = _STRICT + + enabled: bool + mode: Literal["auto_near_roi", "manual"] + radius_px: int + min_distance_px: int + nonblack_threshold: int + min_valid_fraction: float + center_xy: list[float] | None + + @model_validator(mode="after") + def _manual_mode_requires_center(self) -> Self: + """manual 模式没有中心就取不到背景圆,配置错误直接报错。""" + if self.mode == "manual" and self.center_xy is None: + raise ValueError("reference_region.mode=manual 时 center_xy 必填") + return self + + +class TicBaselineConfig(BaseModel): + """增强前基线区间:视频开始到造影剂到达之前那段稳定区间,在上面算基线均值和标准差。""" + + model_config = _STRICT + + preferred_duration_s: float + minimum_duration_s: float + stability_sd_multiplier: float + + @model_validator(mode="after") + def _durations_ordered_and_positive(self) -> Self: + """两个时长为正且最短 ≤ 首选;写反了「首选取不到退到最短」这条退路就比首选还长。""" + if self.minimum_duration_s <= 0.0 or self.preferred_duration_s <= 0.0: + raise ValueError( + "tic_analysis.baseline 的两个时长必须为正,收到 " + f"preferred={self.preferred_duration_s}, " + f"minimum={self.minimum_duration_s}" + ) + if self.minimum_duration_s > self.preferred_duration_s: + raise ValueError( + "tic_analysis.baseline.minimum_duration_s 必须 <= " + f"preferred_duration_s,收到 minimum={self.minimum_duration_s}, " + f"preferred={self.preferred_duration_s}" + ) + return self + + +class TicFittingConfig(BaseModel): + """TIC 的约束 Lognormal 拟合:候选窗、重复帧门槛、参数边界和质量门。""" + + model_config = _STRICT + + algorithm_version: Literal["lognormal-v2"] + detect_duplicate_frames: bool + candidate_window_s: float + candidate_long_window_s: float + candidate_polyorder: Literal[2] + max_candidate_gap_s: float + duplicate_mean_absdiff_threshold: float + minimum_valid_samples: int + minimum_peak_distance_s: float + minimum_relative_peak_height: float + multi_peak_minimum_separation_s: float + multi_peak_minimum_relative_height: float + multi_peak_maximum_valley_fraction: float + sigma_min: float + sigma_max: float + robust_loss: Literal["soft_l1"] + max_nfev: int + minimum_r2: float + maximum_nrmse: float + maximum_residual_noise_ratio: float + boundary_margin_s: float + estimated_baseline_percentile: float + estimated_baseline_lookback_s: float + maximum_perf_area_step_fraction: float + parameter_boundary_fraction: float + + @field_validator( + "candidate_window_s", + "candidate_long_window_s", + "max_candidate_gap_s", + "duplicate_mean_absdiff_threshold", + "minimum_peak_distance_s", + "multi_peak_minimum_separation_s", + "sigma_min", + "sigma_max", + "max_nfev", + "boundary_margin_s", + "estimated_baseline_lookback_s", + ) + @classmethod + def _positive(cls, value: float) -> float: + if value <= 0: + raise ValueError(f"tic_analysis.fitting 的正数参数必须 >0,收到 {value}") + return value + + @field_validator("minimum_valid_samples") + @classmethod + def _enough_samples(cls, value: int) -> int: + if value < 8: + raise ValueError("tic_analysis.fitting.minimum_valid_samples 必须 >=8") + return value + + @field_validator( + "minimum_relative_peak_height", + "multi_peak_minimum_relative_height", + "multi_peak_maximum_valley_fraction", + "maximum_nrmse", + "maximum_perf_area_step_fraction", + ) + @classmethod + def _unit_fraction(cls, value: float) -> float: + if not 0.0 < value <= 1.0: + raise ValueError(f"tic_analysis.fitting 的比例参数必须在 (0,1],收到 {value}") + return value + + @field_validator("parameter_boundary_fraction") + @classmethod + def _boundary_fraction(cls, value: float) -> float: + if not 0.0 < value < 0.5: + raise ValueError("tic_analysis.fitting.parameter_boundary_fraction 必须在 (0,0.5) 内") + return value + + @field_validator("maximum_residual_noise_ratio") + @classmethod + def _positive_noise_ratio(cls, value: float) -> float: + if value <= 0.0: + raise ValueError("maximum_residual_noise_ratio 必须 >0") + return value + + @field_validator("minimum_r2") + @classmethod + def _r2_range(cls, value: float) -> float: + if not -1.0 <= value <= 1.0: + raise ValueError(f"minimum_r2 必须在 [-1,1],收到 {value}") + return value + + @field_validator("estimated_baseline_percentile") + @classmethod + def _baseline_percentile(cls, value: float) -> float: + if not 0.0 < value < 50.0: + raise ValueError("estimated_baseline_percentile 必须在 (0,50) 内") + return value + + @model_validator(mode="after") + def _sigma_bounds_ordered(self) -> Self: + if self.sigma_min >= self.sigma_max: + raise ValueError("tic_analysis.fitting.sigma_min 必须 < sigma_max") + return self + + +class AnchorArrivalConfig(BaseModel): + """造影剂到达判据:门槛取「基线标准差 × 倍数」与「峰高 × 比例」的高者,越线后须持续一段时间。""" + + model_config = _STRICT + + baseline_sd_multiplier: float + relative_peak_threshold: float + persistence_s: float + + @field_validator("baseline_sd_multiplier", "relative_peak_threshold", "persistence_s") + @classmethod + def _positive(cls, value: float) -> float: + """三个判据数都须为正;0 或负值会让门槛恒真或恒假而不报错。""" + if value <= 0.0: + raise ValueError(f"anchor_frames.arrival 判据数必须为正,收到 {value}") + return value + + +class AnchorLatePhaseConfig(BaseModel): + """F2(推注后固定时点的晚期帧)的锚点秒数与容差。""" + + model_config = _STRICT + + preferred_time_s: float + tolerance_s: float + + @field_validator("preferred_time_s", "tolerance_s") + @classmethod + def _positive(cls, value: float) -> float: + """锚点秒数与容差都须为正。""" + if value <= 0.0: + raise ValueError(f"anchor_frames.late_phase 的数必须为正,收到 {value}") + return value + + +class AnchorFramesConfig(BaseModel): + """7 张关键帧的定位参数。 + + `peak_prominence_baseline_sd_multiplier` 与 `arrival.baseline_sd_multiplier` 是两个独立的数 + (即便取值相同),改一个不连带改另一个,所以是两个键。 + """ + + model_config = _STRICT + + arrival: AnchorArrivalConfig + peak_prominence_baseline_sd_multiplier: float + baseline_frame_backoff_s: float + d1_window_after_s: float + d2_window_s: float + d3_window_s: float + d4_window_s: float + b0_window_s: float + late_phase: AnchorLatePhaseConfig + f1_anchor_s: float + + @field_validator( + "peak_prominence_baseline_sd_multiplier", + "baseline_frame_backoff_s", + "d1_window_after_s", + "d2_window_s", + "d3_window_s", + "d4_window_s", + "b0_window_s", + "f1_anchor_s", + ) + @classmethod + def _positive(cls, value: float) -> float: + """倍数、退让、容差、锚点秒数都须为正;0 会让窗口退化成一个点。""" + if value <= 0.0: + raise ValueError(f"anchor_frames 的倍数与容差必须为正,收到 {value}") + return value + + +class TicAnalysisConfig(BaseModel): + """TIC 四条曲线(病灶整体、病灶去暗区、紫圈、背景圆)的采样、基线、拟合与关键帧。 + + 旧配置里「在曲线最前面插一个时间 0、值 0 的合成点」的三个键已删:那个点不是量出来的, + 会在它与首个真实帧之间造出一段最陡的假上升。基线改由 `baseline` 从真实帧里识别。 + """ + + model_config = _STRICT + + brightness_metric: Literal["grayscale_mean"] + exclude_prompt_color: bool + prompt_exclude_dilate: int + baseline: TicBaselineConfig + reference_region: ReferenceRegionConfig + fitting: TicFittingConfig + anchor_frames: AnchorFramesConfig + + +class CeusConfig(BaseModel): + """`config/ceus.yaml` 的 `ceus` 段:CEUS 定量的全部算法参数。 + + `config_version` 标记这一批产物用的是哪一套参数口径,由人改,不从内容哈希派生; + 不许是空串。 + """ + + model_config = _STRICT + + config_version: str = Field(min_length=1) + views: ViewsConfig + prompt_source: PromptSourceConfig + pan_source: PanSourceConfig + keyframes: KeyframesConfig + smoothing: SmoothingConfig + roi_shape: RoiShapeConfig + interior_dark_region: InteriorDarkRegionConfig + constraints: ConstraintsConfig + tic_analysis: TicAnalysisConfig diff --git a/src/cadx/ceus/_contrast_side.py b/src/cadx/ceus/_contrast_side.py new file mode 100644 index 0000000..1a4a1bf --- /dev/null +++ b/src/cadx/ceus/_contrast_side.py @@ -0,0 +1,76 @@ +"""第 2 步:判断造影画面在哪一半,并在那一半上选出提示帧。 + +哪一半是造影画面,各例不一定相同。绿框只画在造影那一半,所以两半各自按同样的办法 +(`collect_prompt_observations` + `select_prompt_frames`,参数相同)选提示帧:恰好一半选出 +至少一帧,这一半就是造影画面;两半都选出或都选不出,这一例失败(`ContrastSideError`)。 +旧代码按配置写死造影在左半,造影在右半的病例就找不到绿框。 +""" + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal + +from cadx.ceus._config import KeyframesConfig, PromptSourceConfig +from cadx.ceus._errors import CeusCaseError +from cadx.ceus._geometry import Frame, ViewRect, crop_frame +from cadx.ceus._green_prompt import collect_prompt_observations +from cadx.ceus._prompt_frames import PromptFrame, select_prompt_frames + +PROMPT_STEP = 2 + +type Side = Literal["left", "right"] + + +class ContrastSideError(CeusCaseError): + """两半都有或都没有绿框,定不出造影画面在哪一半。""" + + def __init__(self, reason: str) -> None: + super().__init__(PROMPT_STEP, reason) + + +@dataclass(frozen=True) +class ContrastSide: + """造影画面在哪一半、两个视图矩形(整帧像素坐标),以及在造影那一半上选出的提示帧。 + + `contrast_view` 是造影那一半(绿框在这里,TIC 在这里采亮度);`target_view` 是另一半 + (分割在这里做)。提示帧的掩膜是 `contrast_view` 的局部坐标。 + """ + + side: Side + contrast_view: ViewRect + target_view: ViewRect + prompt_frames: list[PromptFrame] + + +def _prompt_frames_in( + frames: Sequence[Frame], + view: ViewRect, + prompt_cfg: PromptSourceConfig, + keyframes_cfg: KeyframesConfig, +) -> list[PromptFrame]: + observations = collect_prompt_observations( + (crop_frame(frame, view) for frame in frames), prompt_cfg + ) + return select_prompt_frames(observations, keyframes_cfg) + + +def locate_contrast_side( + frames: Sequence[Frame], + left_view: ViewRect, + right_view: ViewRect, + prompt_cfg: PromptSourceConfig, + keyframes_cfg: KeyframesConfig, +) -> ContrastSide: + """两半各自选提示帧,恰好一半有就定为造影画面;否则抛 `ContrastSideError`。""" + left = _prompt_frames_in(frames, left_view, prompt_cfg, keyframes_cfg) + right = _prompt_frames_in(frames, right_view, prompt_cfg, keyframes_cfg) + if left and not right: + return ContrastSide("left", left_view, right_view, left) + if right and not left: + return ContrastSide("right", right_view, left_view, right) + if left and right: + raise ContrastSideError( + f"左右两半都找到绿框(左半 {len(left)} 个提示帧,右半 {len(right)} 个)," + "定不出造影画面在哪一半" + ) + raise ContrastSideError("左右两半都没有找到绿框,选不出提示帧") diff --git a/src/cadx/ceus/_errors.py b/src/cadx/ceus/_errors.py new file mode 100644 index 0000000..ac026d1 --- /dev/null +++ b/src/cadx/ceus/_errors.py @@ -0,0 +1,15 @@ +"""只落在一段视频上的失败。 + +CEUS 定量第 1–6 步里,某一例的视频不满足算法前提(找不到分屏线、两半都有或都没有绿框等)时 +抛 `CeusCaseError`:这一例记进 `failed.csv`,接着跑下一例(`AGENTS.md`「错误怎么处理」)。 +其余异常是程序或配置的问题,整次运行停下。 +""" + + +class CeusCaseError(Exception): + """一例失败:失败在第几步(结构.md 第四部分的 1–9 步)、原因。""" + + def __init__(self, step: int, reason: str) -> None: + super().__init__(f"第 {step} 步:{reason}") + self.step = step + self.reason = reason diff --git a/src/cadx/ceus/_geometry.py b/src/cadx/ceus/_geometry.py new file mode 100644 index 0000000..e879a36 --- /dev/null +++ b/src/cadx/ceus/_geometry.py @@ -0,0 +1,202 @@ +"""几何、重采样与平滑的纯函数,供分屏、绿框、提示帧、多边形、TIC 等模块共用。 + +- `ViewRect`:整数像素坐标的不可变矩形; +- 矩形:`rect_from_values`、`crop_frame`、`local_rect_within`; +- 掩膜:`mask_iou`、`polygon_to_mask`、`polygons_to_mask`; +- 多边形:`polygon_area`、`polygon_center`、`resample_closed_contour`; +- 时间平滑:`triangular_smooth`;配置解析:`odd_window`、`optional_positive_int`。 + +算法等价于伙伴参考组件 `references/ROI/roi_tracking_pipeline.py`(经旧项目重写)。 +""" + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any + +import cv2 +import numpy as np +import numpy.typing as npt + +type Mask = npt.NDArray[np.bool_] +type Points = npt.NDArray[np.float32] +type Frame = npt.NDArray[np.uint8] +type AnyArray = npt.NDArray[Any] + + +@dataclass(frozen=True) +class ViewRect: + """整数像素坐标的矩形,左闭右开 `[x1, x2) × [y1, y2)`。 + + 构造时强制 `0 <= x1 < x2` 且 `0 <= y1 < y2`,非法矩形直接报错,不让退化或负坐标往下游走。 + """ + + x1: int + y1: int + x2: int + y2: int + + def __post_init__(self) -> None: + if not (0 <= self.x1 < self.x2 and 0 <= self.y1 < self.y2): + raise ValueError( + f"非法 ViewRect:要求 0<=x1 int: + """宽度 `x2 - x1`。""" + return self.x2 - self.x1 + + @property + def height(self) -> int: + """高度 `y2 - y1`。""" + return self.y2 - self.y1 + + +def mask_iou(mask_a: AnyArray, mask_b: AnyArray) -> float: + """两个二值掩膜的交并比;并集为空时返回 0.0。""" + intersection = np.logical_and(mask_a, mask_b).sum() + union = np.logical_or(mask_a, mask_b).sum() + return float(intersection / union) if union else 0.0 + + +def crop_frame[T: np.generic](frame: npt.NDArray[T], rect: ViewRect) -> npt.NDArray[T]: + """按矩形切出子图 `frame[y1:y2, x1:x2]`(视图切片,不复制)。""" + return frame[rect.y1 : rect.y2, rect.x1 : rect.x2] + + +def local_rect_within(parent: ViewRect, child: ViewRect) -> ViewRect: + """把 `child` 换算成相对 `parent` 左上角的坐标;越界时 `ViewRect` 构造报错。""" + return ViewRect( + child.x1 - parent.x1, + child.y1 - parent.y1, + child.x2 - parent.x1, + child.y2 - parent.y1, + ) + + +def rect_from_values(values: Sequence[float], width: int, height: int) -> ViewRect: + """把 `[x1, y1, x2, y2]` 解析成像素矩形。 + + 四个值都在 [0, 1] 时按归一化坐标乘宽高,否则按像素取整。结果裁到画面内,并保证 + `x2 >= x1 + 1`、`y2 >= y1 + 1`,不出退化矩形。 + """ + if len(values) != 4: + raise ValueError("手动裁剪坐标必须是 [x1, y1, x2, y2] 四元组。") + if all(0.0 <= float(value) <= 1.0 for value in values): + scales = (width, height, width, height) + x1, y1, x2, y2 = ( + round(float(value) * scale) for value, scale in zip(values, scales, strict=True) + ) + else: + x1, y1, x2, y2 = (round(float(value)) for value in values) + x1 = int(np.clip(x1, 0, width - 1)) + y1 = int(np.clip(y1, 0, height - 1)) + x2 = int(np.clip(x2, x1 + 1, width)) + y2 = int(np.clip(y2, y1 + 1, height)) + return ViewRect(x1, y1, x2, y2) + + +def polygon_to_mask(polygon: AnyArray, height: int, width: int) -> Mask: + """把多边形(`(N, 2)`,x、y 像素坐标)栅格化为 `(height, width)` 的布尔掩膜。""" + canvas = np.zeros((height, width), dtype=np.uint8) + points = np.round(polygon).astype(np.int32).reshape(-1, 1, 2) + cv2.fillPoly(canvas, [points], (255,)) + return canvas > 0 + + +def polygons_to_mask(polygons: Sequence[AnyArray], height: int, width: int) -> Mask: + """把「外轮廓 + 洞」的多边形列表一次栅格化成带孔掩膜。 + + 整个列表放进同一次 `cv2.fillPoly`:一次调用里的多条轮廓按奇偶规则填充,被包住的内轮廓 + 成为孔。逐个多边形各自栅格化再取并集会把洞当成外轮廓填回去,暗区面积就虚大了。 + 顶点数少于 3 的项跳过;全部退化时返回全 False。 + """ + canvas = np.zeros((height, width), dtype=np.uint8) + contours = [ + np.round(polygon).astype(np.int32).reshape(-1, 1, 2) + for polygon in polygons + if len(polygon) >= 3 + ] + if contours: + cv2.fillPoly(canvas, contours, (255,)) + return canvas > 0 + + +def polygon_area(polygon: AnyArray) -> float: + """鞋带公式算多边形面积(像素²)。直接在浮点顶点上算、不取整,避免量化误差。""" + points = np.asarray(polygon, dtype=np.float64) + x = points[:, 0] + y = points[:, 1] + cross = x * np.roll(y, -1) - np.roll(x, -1) * y + return abs(0.5 * float(cross.sum())) + + +def polygon_center(polygon: AnyArray) -> Points: + """多边形质心(一阶矩);面积退化到约 0 时取顶点均值,避免除零。""" + contour = np.round(polygon).astype(np.float32).reshape(-1, 1, 2) + moments = cv2.moments(contour) + if abs(moments["m00"]) > 1e-6: + return np.array( + [moments["m10"] / moments["m00"], moments["m01"] / moments["m00"]], + dtype=np.float32, + ) + return np.asarray(polygon.mean(axis=0), dtype=np.float32) + + +def resample_closed_contour(contour: AnyArray | None, num_points: int) -> Points | None: + """沿弧长把闭合轮廓等距重采样到 `num_points` 个点。 + + 轮廓为 None、顶点少于 3 个或周长不超过 1 像素时返回 None。 + """ + if contour is None or len(contour) < 3: + return None + points = np.asarray(contour, dtype=np.float32) + closed = np.vstack([points, points[0]]) + deltas = np.diff(closed, axis=0) + segment_lengths = np.sqrt((deltas * deltas).sum(axis=1)) + perimeter = float(segment_lengths.sum()) + if perimeter <= 1.0: + return None + cumulative = np.concatenate([[0.0], np.cumsum(segment_lengths)]) + samples = np.linspace(0.0, perimeter, num_points, endpoint=False) + resampled = np.zeros((num_points, 2), dtype=np.float32) + resampled[:, 0] = np.interp(samples, cumulative, closed[:, 0]) + resampled[:, 1] = np.interp(samples, cumulative, closed[:, 1]) + return resampled + + +def triangular_smooth(values: AnyArray, window: int) -> AnyArray: + """沿第 0 轴(时间)做三角加权滑动平均。 + + 权重 `1, 2, …, r+1, …, 2, 1` 归一化,边界复制端值。窗口 ≤1 时原样返回副本;偶数窗口没有 + 中心,报错。 + """ + if window <= 1: + return values.copy() + if window % 2 == 0: + raise ValueError("时间平滑窗口必须为奇数。") + radius = window // 2 + ramp = np.arange(1, radius + 2, dtype=np.float32) + weights = np.concatenate([ramp, ramp[-2::-1]]) + weights /= weights.sum() + flat = values.reshape(values.shape[0], -1) + padded = np.pad(flat, ((radius, radius), (0, 0)), mode="edge") + smoothed = np.zeros_like(flat) + for index in range(flat.shape[0]): + smoothed[index] = np.sum(padded[index : index + window] * weights[:, None], axis=0) + return smoothed.reshape(values.shape) + + +def odd_window(value: int) -> int: + """把整数变成不小于 1 的奇数:偶数加 1,小于 1 的取 1。""" + floored = max(int(value), 1) + return floored if floored % 2 == 1 else floored + 1 + + +def optional_positive_int(value: Any) -> int | None: + """可选的正整数配置:None、"auto" 或不大于 0 的值都返回 None,其余按整数解析。""" + if value is None or value == "auto": + return None + parsed = int(value) + return parsed if parsed > 0 else None diff --git a/src/cadx/ceus/_green_prompt.py b/src/cadx/ceus/_green_prompt.py new file mode 100644 index 0000000..8a01438 --- /dev/null +++ b/src/cadx/ceus/_green_prompt.py @@ -0,0 +1,186 @@ +"""第 2 步的绿框检测:在已切出的一半画面上找医生画的彩色轮廓。 + +- `prompt_color_mask`:TIC 采样时要从亮度统计里剔除医生画上去的线,这里给出那组颜色的掩膜, + 受 `exclude_prompt_color` 开关和 `prompt_exclude_dilate` 膨胀核控制; +- `extract_color_prompt`:HSV 范围内的像素 → 抹掉顶部文字带 → 形态学闭运算加膨胀把断开的描边 + 连起来 → 外轮廓 → 按外接框面积 w×h 过滤、取最大者 → 实心填充成掩膜;没有候选返回 None; +- `collect_prompt_observations`:逐帧调上一个函数,没检出的帧用 None 占位,帧号与下标对齐。 + +颜色来自参数:喂绿框那组配置检绿框,喂紫圈那组配置(`PanSourceConfig`)检紫圈。帧是已经切出的 +一半画面,返回的外接框和掩膜都是这一半的局部坐标。 +""" + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import Protocol + +import cv2 +import numpy as np + +from cadx.ceus._config import PromptSourceConfig, TicAnalysisConfig +from cadx.ceus._geometry import AnyArray, Frame, Mask, rect_from_values + +# 形态学结构元:5×5,闭运算与膨胀各一次。 +_MORPH_KERNEL = np.ones((5, 5), dtype=np.uint8) + + +class _ColorHsvConfigLike(Protocol): + """一组 HSV 颜色范围(`PromptSourceConfig`、`PanSourceConfig` 都符合)。""" + + @property + def hsv_lower(self) -> list[int]: ... + @property + def hsv_upper(self) -> list[int]: ... + + +class _ColorContourConfigLike(_ColorHsvConfigLike, Protocol): + """一整套彩色轮廓检测配置:HSV 范围、忽略区、面积下限。""" + + @property + def ignore_regions(self) -> list[list[float]]: ... + @property + def min_area(self) -> int: ... + + +@dataclass(frozen=True) +class ColorPrompt: + """一帧的检测结果(这一半画面的局部坐标)。 + + `mask` 是最大候选轮廓实心填充的布尔掩膜;`contour` 是 `cv2.findContours` 原样的 `(N, 1, 2)` + int32 轮廓点;`bbox_xyxy` 是含端点的外接框(`x2 = x + w - 1`);`bbox_area` 是外接框面积 w×h, + 用于 `min_area` 过滤和排序;`contour_area` 只在外接框面积相同时作次级排序;`candidate_count` + 是通过 `min_area` 的候选数。 + """ + + mask: Mask + contour: AnyArray + bbox_xyxy: tuple[int, int, int, int] + bbox_area: int + contour_area: float + candidate_count: int + + @property + def width(self) -> int: + """含端点的外接框宽度 `x2 - x1 + 1`。""" + return self.bbox_xyxy[2] - self.bbox_xyxy[0] + 1 + + @property + def height(self) -> int: + """含端点的外接框高度 `y2 - y1 + 1`。""" + return self.bbox_xyxy[3] - self.bbox_xyxy[1] + 1 + + +@dataclass(frozen=True) +class PromptObservation: + """一帧的观测:帧号和检测结果(没检出时为 None)。""" + + frame_index: int + prompt: ColorPrompt | None + + @property + def detected(self) -> bool: + """这一帧是否检出。""" + return self.prompt is not None + + +def prompt_color_mask( + source_bgr: Frame, + color_cfg: _ColorHsvConfigLike, + tic_cfg: TicAnalysisConfig, +) -> Mask: + """TIC 采样时要剔除的那组颜色的像素掩膜。 + + `exclude_prompt_color` 关闭时返回全 False(不剔除任何像素);开启时取 HSV 范围内的像素, + `prompt_exclude_dilate` 大于 1 时再按这个核大小膨胀一次(核为 1 时膨胀是恒等操作)。 + """ + if not tic_cfg.exclude_prompt_color: + return np.zeros(source_bgr.shape[:2], dtype=bool) + lower = np.asarray(color_cfg.hsv_lower, dtype=np.uint8) + upper = np.asarray(color_cfg.hsv_upper, dtype=np.uint8) + hsv = cv2.cvtColor(source_bgr, cv2.COLOR_BGR2HSV) + mask: Mask = np.asarray(cv2.inRange(hsv, lower, upper)) > 0 + kernel_size = tic_cfg.prompt_exclude_dilate + if kernel_size > 1: + kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8) + mask = np.asarray(cv2.dilate(mask.astype(np.uint8) * 255, kernel, iterations=1)) > 0 + return mask + + +def _apply_ignore_regions(mask: AnyArray, regions: Sequence[Sequence[float]]) -> None: + """把忽略区(归一化或像素坐标的矩形)在掩膜上原地置 0,用来抹掉顶部文字带。""" + height, width = mask.shape + for region in regions: + rect = rect_from_values(list(region), width, height) + mask[rect.y1 : rect.y2, rect.x1 : rect.x2] = 0 + + +@dataclass(frozen=True) +class _Candidate: + contour: AnyArray + bbox_xyxy: tuple[int, int, int, int] + bbox_area: int + contour_area: float + + +def _largest_bbox_candidate( + contours: Sequence[AnyArray], min_area: int +) -> tuple[_Candidate, int] | None: + """按外接框面积过滤、取最大的候选;返回它和候选总数,没有合格候选时返回 None。""" + candidates: list[_Candidate] = [] + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + bbox_area = int(w * h) + if bbox_area < min_area: + continue + candidates.append( + _Candidate( + contour=contour, + bbox_xyxy=(int(x), int(y), int(x + w - 1), int(y + h - 1)), + bbox_area=bbox_area, + contour_area=float(cv2.contourArea(contour)), + ) + ) + if not candidates: + return None + candidates.sort(key=lambda item: (item.bbox_area, item.contour_area), reverse=True) + return candidates[0], len(candidates) + + +def extract_color_prompt( + source_bgr: Frame, prompt_cfg: _ColorContourConfigLike +) -> ColorPrompt | None: + """在一半画面的一帧上找医生画的彩色轮廓,返回最大的合格候选;没有返回 None。""" + lower = np.asarray(prompt_cfg.hsv_lower, dtype=np.uint8) + upper = np.asarray(prompt_cfg.hsv_upper, dtype=np.uint8) + hsv = cv2.cvtColor(source_bgr, cv2.COLOR_BGR2HSV) + mask = np.asarray(cv2.inRange(hsv, lower, upper), dtype=np.uint8) + _apply_ignore_regions(mask, prompt_cfg.ignore_regions) + + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, _MORPH_KERNEL, iterations=1) + mask = cv2.dilate(mask, _MORPH_KERNEL, iterations=1) + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + selected = _largest_bbox_candidate([np.asarray(c) for c in contours], prompt_cfg.min_area) + if selected is None: + return None + best, candidate_count = selected + filled = np.zeros(mask.shape, dtype=np.uint8) + cv2.drawContours(filled, [best.contour], -1, (255,), thickness=-1) + return ColorPrompt( + mask=filled > 0, + contour=best.contour, + bbox_xyxy=best.bbox_xyxy, + bbox_area=best.bbox_area, + contour_area=best.contour_area, + candidate_count=candidate_count, + ) + + +def collect_prompt_observations( + source_frames: Iterable[Frame], prompt_cfg: PromptSourceConfig +) -> list[PromptObservation]: + """逐帧找绿框,交出与帧序列等长的观测列表;没检出的帧 `prompt` 为 None。""" + return [ + PromptObservation(frame_index=index, prompt=extract_color_prompt(frame, prompt_cfg)) + for index, frame in enumerate(source_frames) + ] diff --git a/src/cadx/ceus/_mapping.py b/src/cadx/ceus/_mapping.py new file mode 100644 index 0000000..4ceaf36 --- /dev/null +++ b/src/cadx/ceus/_mapping.py @@ -0,0 +1,67 @@ +"""两半画面之间的坐标换算。 + +绿框画在造影那一半;分割在另一半(灰阶画面,病灶边界不随造影剂进出明暗变化)上做,轮廓再挪回 +造影那一半采亮度。两半是同一个探头视野的两路成像,大小相同、只差一个水平平移,所以换算只做平移: + + 平移量 = 目标那一半视图的左上角 − 造影那一半视图的左上角(整帧像素坐标) + +两个视图矩形直接来自第 1 步的分屏结果,不依赖视频宽度。旧代码改用按 1856 像素宽的视频写死的 +锚点,其余宽度的视频会挪偏一两百像素(design 0002 决定 4),这里不再用锚点。 + +两个函数都在各自视图的局部坐标里收发掩膜和多边形:局部 → 整帧(加本视图左上角)→ 平移 → +目标视图局部(减目标视图左上角)。两半宽度若差几个像素,超出目标视图的部分裁掉,不缩放。 +""" + +import numpy as np + +from cadx.ceus._geometry import AnyArray, Mask, Points, ViewRect + + +def view_offset(source_view_rect: ViewRect, target_view_rect: ViewRect) -> tuple[int, int]: + """从造影那一半到目标那一半的平移量 `(dx, dy)`:两个视图左上角之差。""" + return ( + target_view_rect.x1 - source_view_rect.x1, + target_view_rect.y1 - source_view_rect.y1, + ) + + +def map_source_mask_to_target( + source_mask: AnyArray, + source_view_rect: ViewRect, + target_view_rect: ViewRect, +) -> Mask: + """把造影那一半局部坐标的掩膜挪到目标那一半的局部坐标,形状为目标视图大小。""" + dx, dy = view_offset(source_view_rect, target_view_rect) + # 源局部 (x, y) → 整帧 (x + sx1, y + sy1) → 平移 → 目标局部 (x + sx1 + dx − tx1, …)。 + shift_x = source_view_rect.x1 + dx - target_view_rect.x1 + shift_y = source_view_rect.y1 + dy - target_view_rect.y1 + mapped = np.zeros((target_view_rect.height, target_view_rect.width), dtype=bool) + ys, xs = np.nonzero(source_mask) + xs = xs + shift_x + ys = ys + shift_y + inside = (xs >= 0) & (xs < target_view_rect.width) & (ys >= 0) & (ys < target_view_rect.height) + mapped[ys[inside], xs[inside]] = True + return mapped + + +def target_polygons_to_source( + target_polygons: list[AnyArray], + source_view_rect: ViewRect, + target_view_rect: ViewRect, +) -> list[Points]: + """把目标那一半局部坐标的多边形逐个挪回造影那一半的局部坐标,裁到视图边界内。""" + dx, dy = view_offset(source_view_rect, target_view_rect) + shift = np.array( + [ + target_view_rect.x1 - dx - source_view_rect.x1, + target_view_rect.y1 - dy - source_view_rect.y1, + ], + dtype=np.float32, + ) + mapped: list[Points] = [] + for polygon in target_polygons: + source_poly = polygon.astype(np.float32) + shift[None, :] + source_poly[:, 0] = np.clip(source_poly[:, 0], 0, source_view_rect.width - 1) + source_poly[:, 1] = np.clip(source_poly[:, 1], 0, source_view_rect.height - 1) + mapped.append(source_poly) + return mapped diff --git a/src/cadx/ceus/_prompt_frames.py b/src/cadx/ceus/_prompt_frames.py new file mode 100644 index 0000000..c479dfa --- /dev/null +++ b/src/cadx/ceus/_prompt_frames.py @@ -0,0 +1,78 @@ +"""第 2 步的提示帧选择:绿框位置或形状明显变化的那几帧,交给分割模型当「病灶在这里」的提示。 + +这批帧不是第 8 步的 7 张关键帧。规则: +- 第一个检出绿框的帧必选,`change_iou` 记 1.0; +- 之后每帧先过最小间隔门(距上一个提示帧不足 `min_gap_frames` 帧的跳过),再算它的绿框掩膜与 + 上一个提示帧的 IoU,严格小于 `iou_threshold` 才记新提示帧(恰好等于不记); +- 没检出绿框的帧跳过,不占第一帧的位置。 +""" + +from dataclasses import dataclass + +from cadx.ceus._config import KeyframesConfig +from cadx.ceus._geometry import Mask, mask_iou +from cadx.ceus._green_prompt import PromptObservation + +# 间隔门的初值:保证第一个检出绿框的帧总能通过。 +_NO_PREVIOUS_PROMPT_FRAME = -(10**9) + + +@dataclass(frozen=True) +class PromptFrame: + """一个提示帧(这一半画面的局部坐标)。 + + `mask` 是这一帧绿框的实心掩膜,既是给分割的提示,也是下一个提示帧比 IoU 的基准; + `change_iou` 是相对上一个提示帧的 IoU,第一帧记 1.0。 + """ + + frame_index: int + mask: Mask + bbox_xyxy: tuple[int, int, int, int] + bbox_area: int + contour_area: float + change_iou: float + + +def select_prompt_frames( + observations: list[PromptObservation], prompt_frame_cfg: KeyframesConfig +) -> list[PromptFrame]: + """从逐帧观测中选出提示帧,按帧号升序;没有任何检出时返回空列表。""" + if prompt_frame_cfg.change_metric != "mask_iou": + raise RuntimeError( + f"keyframes.change_metric 仅支持 'mask_iou',得到 '{prompt_frame_cfg.change_metric}'。" + ) + + iou_threshold = prompt_frame_cfg.iou_threshold + min_gap = prompt_frame_cfg.min_gap_frames + + prompt_frames: list[PromptFrame] = [] + last_key_mask: Mask | None = None + last_key_frame = _NO_PREVIOUS_PROMPT_FRAME + for observation in observations: + prompt = observation.prompt + if prompt is None: + continue + frame_index = observation.frame_index + if frame_index - last_key_frame < min_gap: + continue + if last_key_mask is None: + change_iou = 1.0 + is_new_prompt_frame = True + else: + change_iou = mask_iou(last_key_mask, prompt.mask) + is_new_prompt_frame = change_iou < iou_threshold + if not is_new_prompt_frame: + continue + prompt_frames.append( + PromptFrame( + frame_index=frame_index, + mask=prompt.mask, + bbox_xyxy=prompt.bbox_xyxy, + bbox_area=prompt.bbox_area, + contour_area=prompt.contour_area, + change_iou=change_iou, + ) + ) + last_key_mask = prompt.mask + last_key_frame = frame_index + return prompt_frames diff --git a/src/cadx/ceus/_split_screen.py b/src/cadx/ceus/_split_screen.py new file mode 100644 index 0000000..fa3c2ef --- /dev/null +++ b/src/cadx/ceus/_split_screen.py @@ -0,0 +1,186 @@ +"""第 1 步的分屏:找出左右两半之间的竖直暗缝,切出两个视图矩形。 + +超声机录造影时屏幕左右各一半,分界不一定正好在正中间,所以从帧内容里找:在中部一段列范围内 +取列均值最低、且明显低于周围的那一列。找不到可信的缝就报错,不退回 `W//2`。 + +照旧 debug 路径(40 例结果是它跑出来的):找缝只用均匀抽的若干帧;不裁画面黑边,视图矩形就是 +左右两半本身。 +""" + +from collections.abc import Iterable, Mapping, Sequence + +import cv2 +import numpy as np +import numpy.typing as npt + +from cadx.ceus._config import ViewsConfig +from cadx.ceus._errors import CeusCaseError +from cadx.ceus._geometry import Frame, ViewRect, rect_from_values + +SPLIT_STEP = 1 + + +class SplitDetectionError(CeusCaseError): + """这段视频找不到可信的分屏线(无帧、无暗缝、切分越界或左右失衡)。""" + + def __init__(self, reason: str) -> None: + super().__init__(SPLIT_STEP, reason) + + +def _mean_gray(frames: Iterable[Frame]) -> npt.NDArray[np.float32]: + """若干帧取平均后转灰度。边收边累加、不把帧堆成一个数组;累加器用 float64,帧多时不丢低位。""" + total: npt.NDArray[np.float64] | None = None + count = 0 + for frame in frames: + if total is None: + total = np.zeros(frame.shape, dtype=np.float64) + elif frame.shape != total.shape: + raise SplitDetectionError( + f"帧尺寸不一致:首帧 {total.shape},第 {count} 帧 {frame.shape}。" + ) + total += frame + count += 1 + if total is None: + raise SplitDetectionError("帧序列为空,无法求跨帧平均灰度。") + mean_bgr = (total / count).astype(np.uint8) + return np.asarray(cv2.cvtColor(mean_bgr, cv2.COLOR_BGR2GRAY), dtype=np.float32) + + +def detect_split_x(frames: Iterable[Frame], *, search_band: tuple[float, float]) -> int: + """找左右分屏的竖直分界列 `split_x`。 + + `search_band` 是搜索区间占宽度的比例 `(lo, hi)`,只在中部找,排除画面内偶发的暗带。 + 在区间内取列均值最低的列;它的列均值须低于区间中位列均值的 70%(否则视为没有缝, + 例如整幅平坦或全黑);再要求左右宽度比在 [0.5, 2.0]。任一不满足报 `SplitDetectionError`。 + """ + lo_ratio, hi_ratio = search_band + if not 0.0 <= lo_ratio < hi_ratio <= 1.0: + raise SplitDetectionError(f"非法 search_band:要求 0<=lo= band_median * 0.7: + raise SplitDetectionError( + f"中部 band 内未找到稳定暗缝(谷值 {valley_value:.1f} 未显著低于 " + f"中位 {band_median:.1f}),疑为无缝或全黑画面。" + ) + + if not band_lo <= split_x < band_hi: + raise SplitDetectionError( + f"检测到的 split_x={split_x} 落在搜索 band [{band_lo}, {band_hi}) 外。" + ) + left_width = split_x + right_width = width - split_x + if left_width < 1 or right_width < 1: + raise SplitDetectionError( + f"切分线 split_x={split_x} 使某一侧宽度退化(左 {left_width} / 右 {right_width})。" + ) + width_ratio = left_width / right_width + if not 0.5 <= width_ratio <= 2.0: + raise SplitDetectionError( + f"左右宽度比 {width_ratio:.2f} 超出 [0.5, 2.0]" + f"(左 {left_width} / 右 {right_width}),切分线不可信。" + ) + return split_x + + +def _horizontal_split_rect( + view_name: str, width: int, height: int, split_x: int | None +) -> ViewRect | None: + """左右分屏的视图矩形,分界用检测到的 `split_x`。""" + if split_x is None: + raise ValueError("horizontal_split 布局必须传入检测到的 split_x。") + if view_name == "left": + return ViewRect(0, 0, split_x, height) + if view_name == "right": + return ViewRect(split_x, 0, width, height) + return None + + +def _vertical_split_rect(view_name: str, width: int, height: int) -> ViewRect | None: + """上下分屏的视图矩形(分界取 H//2)。""" + mid = height // 2 + if view_name == "top": + return ViewRect(0, 0, width, mid) + if view_name == "bottom": + return ViewRect(0, mid, width, height) + return None + + +def _manual_crop_rect( + view_cfg: Mapping[str, object], view_name: str, width: int, height: int +) -> ViewRect: + """手工裁剪布局:从 `crops` 取对应视图的框,没有就报错。""" + crops = view_cfg["crops"] + if not isinstance(crops, Mapping) or view_name not in crops: + raise RuntimeError(f"手动裁剪中未找到视图 {view_name!r}。") + values: object = crops[view_name] # pyright: ignore[reportUnknownVariableType] + if not isinstance(values, list): + raise RuntimeError(f"手动裁剪视图 {view_name!r} 必须是 [x1, y1, x2, y2]。") + return rect_from_values([float(v) for v in values], width, height) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] + + +def view_rect( + view_cfg: Mapping[str, object], + view_name: str, + width: int, + height: int, + *, + split_x: int | None, +) -> ViewRect: + """按布局和视图名算视图矩形(整帧像素坐标)。 + + `view_cfg` 至少含 `layout`,手工裁剪时含 `crops`。左右分屏时 `split_x` 必填。 + 布局与视图名对不上报 `RuntimeError`。 + """ + layout = view_cfg["layout"] + rect: ViewRect | None + if layout == "horizontal_split": + rect = _horizontal_split_rect(view_name, width, height, split_x) + elif layout == "vertical_split": + rect = _vertical_split_rect(view_name, width, height) + elif layout == "full": + rect = ViewRect(0, 0, width, height) + elif layout == "manual_crop": + rect = _manual_crop_rect(view_cfg, view_name, width, height) + else: + rect = None + if rect is None: + raise RuntimeError(f"不支持的 layout/view 组合:{layout}/{view_name}") + return rect + + +def sample_frames_for_split(frames: Sequence[Frame], sample_count: int) -> list[Frame]: + """沿时间轴均匀抽 `sample_count` 帧(下标去重);帧数不超过它时全用。""" + if not frames: + raise SplitDetectionError("帧序列为空,无法找分屏线。") + if sample_count <= 0 or len(frames) <= sample_count: + return list(frames) + indices = np.linspace(0, len(frames) - 1, num=sample_count, dtype=np.int32) + unique_indices = sorted({int(index) for index in indices.tolist()}) + return [frames[index] for index in unique_indices] + + +def split_views(frames: Sequence[Frame], views_cfg: ViewsConfig) -> tuple[int, ViewRect, ViewRect]: + """第 1 步:在均匀抽的帧上找分屏线,交出 `(split_x, 左半矩形, 右半矩形)`。""" + split_x = detect_split_x( + sample_frames_for_split(frames, views_cfg.split_sample_frames), + search_band=(views_cfg.split_search_band[0], views_cfg.split_search_band[1]), + ) + height, width = frames[0].shape[:2] + layout = {"layout": views_cfg.layout} + left = view_rect(layout, "left", width, height, split_x=split_x) + right = view_rect(layout, "right", width, height, split_x=split_x) + return split_x, left, right diff --git a/src/cadx/ceus/_video.py b/src/cadx/ceus/_video.py new file mode 100644 index 0000000..39a4f2a --- /dev/null +++ b/src/cadx/ceus/_video.py @@ -0,0 +1,90 @@ +"""按路径用 cv2 逐帧读视频。 + +反复遍历靠重新解码,不缓存帧:一段 1260 帧的素材解一遍不到两秒,缓存下来要好几 GB(旧项目实测)。 +每次遍历各开一个 `cv2.VideoCapture`,互不干扰。探测不到有效 fps、帧数或尺寸直接报错,不回退默认值。 +""" + +import math +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +import numpy.typing as npt + +from cadx.ceus._geometry import Frame + + +@dataclass(frozen=True) +class VideoFile: + """磁盘上的一段视频,可以反复从第 0 帧遍历。""" + + path: Path + + def _capture(self) -> cv2.VideoCapture: + cap = cv2.VideoCapture(str(self.path)) + if not cap.isOpened(): + cap.release() + raise RuntimeError(f"cv2 打不开视频:{self.path}") + return cap + + def probe(self) -> tuple[float, int, int, int]: + """视频元数据 `(fps, frame_count, width, height)`,都为正,否则报错。""" + cap = self._capture() + try: + fps = float(cap.get(cv2.CAP_PROP_FPS)) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + finally: + cap.release() + if not math.isfinite(fps) or fps <= 0: + raise RuntimeError(f"探测到非法 fps={fps}:{self.path}") + if frame_count <= 0 or width <= 0 or height <= 0: + raise RuntimeError( + f"探测到非法元数据 frame_count={frame_count} " + f"width={width} height={height}:{self.path}" + ) + return fps, frame_count, width, height + + def iter_frames(self) -> Iterator[tuple[int, Frame]]: + """逐帧产出 `(帧号, BGR 的 H×W×3 uint8)`,帧号从 0 连续递增;每次调用都从头开始。""" + cap = self._capture() + try: + index = 0 + while True: + ok, frame = cap.read() + if not ok: + break + yield index, np.asarray(frame, dtype=np.uint8) + index += 1 + finally: + cap.release() + + def frame_times_s(self) -> npt.NDArray[np.float64]: + """逐帧读容器里的显示时间戳(秒);必须严格递增、帧数与容器记录一致,否则报错。""" + cap = self._capture() + try: + expected_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + times: list[float] = [] + while True: + ok, _frame = cap.read() + if not ok: + break + timestamp_s = float(cap.get(cv2.CAP_PROP_POS_MSEC)) / 1000.0 + if not math.isfinite(timestamp_s) or timestamp_s < 0.0: + raise RuntimeError(f"视频包含非法显示时间戳 {timestamp_s}:{self.path}") + if times and timestamp_s <= times[-1]: + raise RuntimeError( + "视频显示时间戳不严格递增:" + f"第 {len(times)} 帧 {timestamp_s},前一帧 {times[-1]}:{self.path}" + ) + times.append(timestamp_s) + finally: + cap.release() + if len(times) != expected_count: + raise RuntimeError( + f"时间戳帧数与容器记录的帧数不一致:{len(times)} / {expected_count}:{self.path}" + ) + return np.asarray(times, dtype=np.float64) diff --git a/src/cadx/library/__init__.py b/src/cadx/library/__init__.py index 1d796f8..c069344 100644 --- a/src/cadx/library/__init__.py +++ b/src/cadx/library/__init__.py @@ -13,7 +13,7 @@ from cadx.library._read import ( read_ultrasound, ) from cadx.library._scan import LeakHit, scan_for_leaks -from cadx.library._write import LibraryCase, write_library +from cadx.library._write import LibraryCase, check_key_image, write_library __all__ = [ "ClinicalRoute", @@ -21,6 +21,7 @@ __all__ = [ "LibraryCase", "MrRoute", "UltrasoundRoute", + "check_key_image", "list_aliases", "read_clinical", "read_mr", diff --git a/src/cadx/library/_write.py b/src/cadx/library/_write.py index b6420b8..f6cc093 100644 --- a/src/cadx/library/_write.py +++ b/src/cadx/library/_write.py @@ -56,23 +56,33 @@ def _write_json(path: Path, content: object) -> None: path.write_text(json.dumps(content, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +def check_key_image(path: Path) -> str | None: + """关键图能逐像素保真地重编码时返回 None,不能时返回原因。 + + 不能保真的:不是 PNG、位深或颜色类型不在 `_FAITHFUL` 里、带 tRNS 透明色。 + build 在写库前对每例调用,返回原因的整例跳过(C14),不悄悄降位或丢透明。 + """ + header = path.read_bytes()[:26] + if header[:8] != _PNG_SIGNATURE or header[12:16] != b"IHDR": + return "不是 PNG" + bit_depth, color_type = header[24], header[25] + if (bit_depth, color_type) not in _FAITHFUL: + return f"位深 {bit_depth}、颜色类型 {color_type} 不能逐像素保真重编码" + with Image.open(path) as image: + if "transparency" in image.info: + return "带 tRNS 透明色,重编码会丢透明" + return None + + def _write_clean_png(source: Path, target: Path) -> None: """只保留像素和颜色模式重新编码:文本块、EXIF、ICC 等附加数据都不带过去。 - 不能逐像素保真的图(非 PNG、位深或颜色类型不在 `_FAITHFUL` 里、带 tRNS 透明色)报错停下, - 不悄悄降位或丢透明。 + 走到这里的图应当已由 build 用 `check_key_image` 查过;还不能保真说明 build 漏了检查,报错停下。 """ - header = source.read_bytes()[:26] - if header[:8] != _PNG_SIGNATURE or header[12:16] != b"IHDR": - raise ValueError(f"关键图不是 PNG:{source}") - bit_depth, color_type = header[24], header[25] - if (bit_depth, color_type) not in _FAITHFUL: - raise ValueError( - f"关键图位深 {bit_depth}、颜色类型 {color_type} 不能逐像素保真重编码:{source}" - ) + reason = check_key_image(source) + if reason is not None: + raise ValueError(f"关键图{reason},build 应已用 check_key_image 跳过这一例:{source}") with Image.open(source) as image: - if "transparency" in image.info: - raise ValueError(f"关键图带 tRNS 透明色,重编码会丢透明:{source}") clean = Image.frombytes(image.mode, image.size, image.tobytes()) clean.save(target, format="PNG") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8c6a398 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +"""CEUS 测试共用的夹具:读 `config/ceus.yaml` 的 `ceus` 段。 + +配置不设默认值,旧测试里靠默认值构造配置的地方改用这里的取值;需要不同取值的测试用 +`model_copy(update=...)` 改那一项。 +""" + +from pathlib import Path +from typing import Any, cast + +import pytest +import yaml + +from cadx.ceus import CeusConfig + +CEUS_YAML = Path(__file__).resolve().parents[1] / "config" / "ceus.yaml" + + +def load_ceus_section() -> dict[str, Any]: + """`config/ceus.yaml` 的 `ceus` 段原样读成字典(每次新读一份,调用方可以改)。""" + document = cast(dict[str, Any], yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8"))) + return cast(dict[str, Any], document["ceus"]) + + +@pytest.fixture +def ceus_config() -> CeusConfig: + """校验过的 `ceus` 段。""" + return CeusConfig.model_validate(load_ceus_section()) diff --git a/tests/test_build_outputs.py b/tests/test_build_outputs.py index 3eee3a8..2eb50bb 100644 --- a/tests/test_build_outputs.py +++ b/tests/test_build_outputs.py @@ -12,11 +12,13 @@ from PIL import Image from cadx.answers import read_aliases, read_diagnoses from cadx.build import make_aliases, run_build from cadx.library import ( + LibraryCase, list_aliases, read_clinical, read_mr, read_ultrasound, scan_for_leaks, + write_library, ) from fake_delivery import ( CLINICAL_HEADER, @@ -218,24 +220,59 @@ def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None: run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW) -def test_key_image_16_bit_source_stops_build(tmp_path: Path) -> None: - image = Image.new("I;16", (4, 4)) - with pytest.raises(ValueError, match=r"U002_key\.png"): - _build_with_key_image(tmp_path, image) - assert not (tmp_path / "library" / f"{NOW:%Y%m%d-%H%M%S}").exists() - assert [p for p in (tmp_path / "library").iterdir() if not p.name.endswith(".partial")] == [] +def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None: + """002 这一例记 C14 跳过、涉及值是原因;001 照常进库。""" + (library_dir,) = list((tmp_path / "library").iterdir()) + answers_dir = tmp_path / "answers" / library_dir.name + assert [r.case_no for r in read_aliases(answers_dir)] == ["001"] + assert len(list_aliases(library_dir)) == 1 + skipped = _csv_rows(answers_dir / "skipped.csv") + assert [row[:4] for row in skipped[1:]] == [ + ["002", "C14", "关键图能原样重编码", "CEUS_agent/002/U002_key.png"] + ] + assert json.loads(skipped[1][5]) == reason -def test_key_image_with_transparency_stops_build(tmp_path: Path) -> None: +def test_key_image_16_bit_source_skips_case(tmp_path: Path) -> None: + _build_with_key_image(tmp_path, Image.new("I;16", (4, 4))) + _assert_key_image_skipped(tmp_path, "位深 16、颜色类型 0 不能逐像素保真重编码") + + +def test_key_image_with_transparency_skips_case(tmp_path: Path) -> None: image = Image.new("RGB", (4, 4)) image.info["transparency"] = (0, 0, 0) - with pytest.raises(ValueError, match=r"U002_key\.png"): - _build_with_key_image(tmp_path, image) + _build_with_key_image(tmp_path, image) + _assert_key_image_skipped(tmp_path, "带 tRNS 透明色,重编码会丢透明") -def test_key_image_palette_source_stops_build(tmp_path: Path) -> None: - with pytest.raises(ValueError, match=r"U002_key\.png"): - _build_with_key_image(tmp_path, Image.new("P", (4, 4))) +def test_key_image_palette_source_skips_case(tmp_path: Path) -> None: + _build_with_key_image(tmp_path, Image.new("P", (4, 4))) + # 只有一种颜色的调色板图,Pillow 存成 1 位。 + _assert_key_image_skipped(tmp_path, "位深 1、颜色类型 3 不能逐像素保真重编码") + + +def test_key_image_not_png_skips_case(tmp_path: Path) -> None: + root = make_delivery(2).write(tmp_path / "delivery") + Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG") + run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW) + _assert_key_image_skipped(tmp_path, "不是 PNG") + + +def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None: + """build 漏了 C14 检查时,library 写图那一步报错停下。""" + source = tmp_path / "key.png" + Image.new("P", (4, 4)).save(source, format="PNG") + target = tmp_path / "out" + target.mkdir() + case = LibraryCase( + alias="abcd", + clinical_fields={}, + ultrasound_report="", + key_image_source=source, + mr_report="", + ) + with pytest.raises(ValueError, match="check_key_image"): + write_library(target, "b", "c", True, {}, False, (), None, [case]) def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None: diff --git a/tests/test_ceus_config.py b/tests/test_ceus_config.py new file mode 100644 index 0000000..ad1c08f --- /dev/null +++ b/tests/test_ceus_config.py @@ -0,0 +1,396 @@ +"""CEUS ROI 科研配置 schema + loader 单元测试。 + +验证目标: +1. `load_tracking_config` 读默认 yaml 后返回 `TrackingConfig`,且关键默认值 + 等于 findings §2A/§3 的「yaml 生产值」(而非参考代码的保守默认)。 +2. findings §5 硬约束生效(偶数 temporal_window / 面积比逆序 / percentile 越界报错)。 +3. 子模型 `extra="forbid"` 拒绝未知键(防 typo 静默退化)。 +4. findings §2B 死键不在 schema 内(混入即被 extra="forbid" 拒绝)。 +5. design/0030 那几项:合成零点四键已删且残留即报错、`config_version` 必填、 + `pan_source` 校验器、基线默认值等于规范 §3.3。 +""" + +from typing import Any + +import pytest +from pydantic import ValidationError + +from cadx.ceus import CeusConfig as TrackingConfig +from conftest import load_ceus_section + + +def _merge(base: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + """把 payload 逐层盖到 base 上(只盖 payload 里写到的键)。""" + merged = dict(base) + for key, value in payload.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _merge(merged[key], value) # pyright: ignore[reportUnknownArgumentType] + else: + merged[key] = value + return merged + + +def _validate(payload: dict[str, Any]) -> TrackingConfig: + """在 config/ceus.yaml 的 ceus 段上盖上 payload 再校验。 + + 配置不设默认值、每个字段必填;旧测试只写自己关心的那几个键、其余靠默认值, + 这里改为其余键取 config/ceus.yaml 的值。 + """ + return TrackingConfig.model_validate(_merge(load_ceus_section(), payload)) + + +def load_tracking_config() -> TrackingConfig: + """读 config/ceus.yaml 的 ceus 段(旧测试读的是旧 yaml 整份文件)。""" + return TrackingConfig.model_validate(load_ceus_section()) + + +def test_load_default_config_returns_tracking_config() -> None: + """默认 yaml 应能加载为 TrackingConfig 实例。""" + cfg = load_tracking_config() + assert isinstance(cfg, TrackingConfig) + + +def test_parameter_boundary_fraction_cannot_overlap_both_sides() -> None: + with pytest.raises(ValidationError, match="parameter_boundary_fraction"): + _validate({"tic_analysis": {"fitting": {"parameter_boundary_fraction": 0.5}}}) + + +def test_default_values_match_findings_production_values() -> None: + """关键默认值须等于 findings §2A/§3 的 yaml 生产值,而非代码保守默认。""" + cfg = load_tracking_config() + + # findings §2A:删键即静默退化的 14 项中的代表项 + assert cfg.interior_dark_region.enabled is True # 非代码默认 False + assert cfg.interior_dark_region.analysis_view == "source" # 非 target + assert cfg.interior_dark_region.exclude_prompt_color is True # 非 False + assert cfg.interior_dark_region.threshold_method == "auto_ensemble" # 非 otsu + assert cfg.interior_dark_region.min_area == 300 # 非代码默认 64 + assert cfg.interior_dark_region.min_area_ratio == 0.015 # 非 0.0 + assert cfg.roi_shape.source == "prompt_prior" # 非代码默认 medsam2 + assert cfg.constraints.keyframe_position.enabled is True # 非 False + + # findings §3:其余生产值 + assert cfg.keyframes.iou_threshold == 0.92 + assert cfg.keyframes.min_gap_frames == 1 + assert cfg.smoothing.temporal_window == 15 + assert cfg.smoothing.contour_points == 64 + assert cfg.roi_shape.min_area_ratio_to_prompt == 0.70 + assert cfg.roi_shape.max_area_ratio_to_prompt == 1.25 + + # findings §2C/§3.8:暗部方法子参(yaml 未列、代码默认) + assert cfg.interior_dark_region.percentile == 45 + assert cfg.interior_dark_region.adaptive_block_size == 31 + + # design/0044:两条正式病灶曲线使用 Lognormal v2;SG 只做候选检测。 + assert cfg.tic_analysis.fitting.algorithm_version == "lognormal-v2" + assert cfg.tic_analysis.fitting.candidate_polyorder == 2 + assert cfg.tic_analysis.fitting.multi_peak_minimum_separation_s == 5.0 + assert cfg.tic_analysis.fitting.multi_peak_minimum_relative_height == 0.50 + assert cfg.tic_analysis.fitting.multi_peak_maximum_valley_fraction == 0.25 + assert cfg.tic_analysis.fitting.candidate_window_s == 1.5 + assert cfg.tic_analysis.fitting.detect_duplicate_frames is True + + +def test_temporal_window_must_be_odd() -> None: + """findings §5:smoothing.temporal_window 须为正奇数,偶数报错。""" + with pytest.raises(ValidationError): + _validate({"smoothing": {"temporal_window": 14}}) + + +def test_adaptive_block_size_must_be_odd() -> None: + """findings §5:adaptive_block_size 须为正奇数,偶数报错。""" + with pytest.raises(ValidationError): + _validate({"interior_dark_region": {"adaptive_block_size": 30}}) + + +def test_area_ratio_min_must_be_less_than_max() -> None: + """findings §5:min_area_ratio_to_prompt 须 < max,逆序报错。""" + with pytest.raises(ValidationError): + _validate( + { + "roi_shape": { + "min_area_ratio_to_prompt": 1.3, + "max_area_ratio_to_prompt": 1.25, + } + } + ) + + +def test_percentile_must_be_within_bounds() -> None: + """findings §5:暗部 percentile 须 ∈ [0, 100],越界报错。""" + with pytest.raises(ValidationError): + _validate({"interior_dark_region": {"percentile": 150}}) + + +def test_keyframe_position_strength_clipped_to_unit_interval() -> None: + """findings §5:constraints.keyframe_position.strength clip 到 [0, 1]。""" + cfg = _validate({"constraints": {"keyframe_position": {"strength": 1.8}}}) + assert cfg.constraints.keyframe_position.strength == 1.0 + + cfg_neg = _validate({"constraints": {"keyframe_position": {"strength": -0.5}}}) + assert cfg_neg.constraints.keyframe_position.strength == 0.0 + + +def test_extra_keys_forbidden() -> None: + """子模型 extra='forbid':未知键(含 typo / 死键)报错。""" + with pytest.raises(ValidationError): + _validate( + {"interior_dark_region": {"temporal_window": 9}} # findings §2B 死键 + ) + with pytest.raises(ValidationError): + _validate({"unknown_top_level_key": 1}) + + +def test_reference_region_manual_requires_center_xy() -> None: + """findings §3.11:reference_region.mode=manual 时 center_xy 必填,缺则报错。""" + with pytest.raises(ValidationError): + _validate({"tic_analysis": {"reference_region": {"mode": "manual"}}}) + + +def test_reference_region_manual_with_center_xy_passes() -> None: + """findings §3.11:manual 模式提供 center_xy 即合法。""" + cfg = _validate( + {"tic_analysis": {"reference_region": {"mode": "manual", "center_xy": [100.0, 200.0]}}} + ) + assert cfg.tic_analysis.reference_region.mode == "manual" + assert cfg.tic_analysis.reference_region.center_xy == [100.0, 200.0] + + +def test_invalid_layout_rejected() -> None: + """闭集枚举:非法 views.layout 应被 Literal 拒绝(防 typo 穿透)。""" + with pytest.raises(ValidationError): + _validate({"views": {"layout": "diagonal"}}) + + +def test_invalid_threshold_method_rejected() -> None: + """闭集枚举:非法 interior_dark_region.threshold_method 应被 Literal 拒绝。""" + with pytest.raises(ValidationError): + _validate({"interior_dark_region": {"threshold_method": "foobar"}}) + + +def test_invalid_reference_region_mode_rejected() -> None: + """闭集枚举:非法 reference_region.mode 应被 Literal 拒绝。""" + with pytest.raises(ValidationError): + _validate({"tic_analysis": {"reference_region": {"mode": "random"}}}) + + +# ---------- design/0030 决策二:合成零点那四个键已删,残留即报错 ---------- + + +@pytest.mark.parametrize( + "payload", + [ + {"add_zero_timepoint": True}, + {"shift_frame_times_after_zero": True}, + {"zero_time_sec": 0.0}, + {"smoothing": {"force_zero_start": True}}, + ], +) +def test_zero_timepoint_keys_are_gone(payload: dict[str, Any]) -> None: + """design/0030 决策二:合成零点那四个键已删,残留即被 extra='forbid' 拒掉。 + + 理由写在 `TicAnalysisConfig` 的 docstring 上(CLAUDE.md §1.6)。 + 这条守的只是名字,换个名字把同一个概念加回来它照样绿,所以那两段 docstring 是不可省 + 的另一半。 + """ + with pytest.raises(ValidationError): + _validate({"tic_analysis": payload}) + + +def test_anchor_frames_defaults_mirror_contract() -> None: + """anchor_frames 段的默认值镜像自《临床口径契约》(2026-08-25 版,同步 2026-09-01)。 + + 这批数不来自 findings 也不来自工程直觉——动任何一个之前先去 Linear 那边核对。 + 钉在测试里是为了防手滑改配置时漏看注释:改了数这条就红,红的含义是「抄本动过了, + 去核对契约」。 + """ + cfg = load_tracking_config() + a = cfg.tic_analysis.anchor_frames + assert a.arrival.baseline_sd_multiplier == 3.0 + assert a.arrival.relative_peak_threshold == 0.10 + assert a.arrival.persistence_s == 0.5 + # 突出度倍数与到达判据倍数是两个独立的键(契约明写改一个不应连带改另一个)。 + assert a.peak_prominence_baseline_sd_multiplier == 3.0 + assert a.baseline_frame_backoff_s == 0.3 + assert a.d1_window_after_s == 0.5 + assert a.d2_window_s == 0.3 + assert a.d3_window_s == 0.5 + # D4/B0 两档契约空着、第 12 题委托工程侧,取曲线锚点档里较宽的一档。 + assert a.d4_window_s == 0.5 + assert a.b0_window_s == 0.5 + assert a.late_phase.preferred_time_s == 60.0 + assert a.late_phase.tolerance_s == 2.0 + assert a.f1_anchor_s == 20.0 + + +def test_anchor_frames_unknown_key_rejected() -> None: + """schema 拒绝未知键:照抄契约时拼错键名在加载配置这一步就报错,不进流水线。""" + with pytest.raises(ValidationError): + _validate({"tic_analysis": {"anchor_frames": {"arrival_threshold": 3.0}}}) + + +def test_yaml_with_leftover_zero_key_fails_to_load() -> None: + """yaml 里留着 `add_zero_timepoint` 时加载当场失败,不留兼容路径。""" + section = load_ceus_section() + section["tic_analysis"]["add_zero_timepoint"] = True + with pytest.raises(ValidationError): + TrackingConfig.model_validate(section) + + +# ---------- design/0030 决策十:config_version ---------- + + +def test_config_version_is_required() -> None: + """design/0030 决策十:`config_version` 无默认,缺了直接报错。 + + 有默认值等于「yaml 里删掉这行也能起来」,那时两批不同参数跑出来的结果落在库里 + 长得一模一样,没有任何东西能把它们分开。 + """ + section = load_ceus_section() + del section["config_version"] + with pytest.raises(ValidationError, match="config_version"): + TrackingConfig.model_validate(section) + + +def test_config_version_rejects_empty_string() -> None: + """空串在**加载配置这一步**就得被拒,不能等到整条流水线跑完才炸。 + + 实体 ``CeusRoiTracking`` 上有同一道判据,但那是落库前的最后一关:解码、分割、六趟 + 遍历全跑完,走到写库才报错,而这一趟是白跑的。 + """ + with pytest.raises(ValidationError, match="config_version"): + _validate({"config_version": ""}) + + +def test_default_yaml_declares_config_version() -> None: + """默认 yaml 顶层给了 `config_version`。 + + "4" 起于双曲线 Lognormal v2(design/0044):拟合与锚点语义发生变化, + 两批产物靠这个号分开(design/0030 决策十)。 + """ + cfg = load_tracking_config() + assert cfg.config_version == "4" + + +# ---------- design/0030 决策二:基线区间默认值 ---------- + + +def test_baseline_defaults_match_spec_section_3_3() -> None: + """基线两个时长的默认值等于规范 §3.3 给的首选 2 秒 / 最短 1 秒。""" + cfg = load_tracking_config() + assert cfg.tic_analysis.baseline.preferred_duration_s == 2.0 + assert cfg.tic_analysis.baseline.minimum_duration_s == 1.0 + # 规范 §3.3 没给稳定性判据,这个数是我们定的占位。 + assert cfg.tic_analysis.baseline.stability_sd_multiplier == 3.0 + + +def test_baseline_minimum_must_not_exceed_preferred() -> None: + """最短时长超过首选时长即报错——那样「退到最短」比首选还长,等于没有退路。""" + with pytest.raises(ValidationError): + _validate( + { + "tic_analysis": { + "baseline": { + "preferred_duration_s": 1.0, + "minimum_duration_s": 2.0, + } + } + } + ) + + +def test_baseline_durations_must_be_positive() -> None: + """非正时长即报错(0 秒的基线段算不出 B 与 σ0)。""" + with pytest.raises(ValidationError): + _validate({"tic_analysis": {"baseline": {"minimum_duration_s": 0.0}}}) + + +# ---------- design/0030 决策四:pan_source(紫色轮廓检测器) ---------- + + +def test_pan_source_defaults_match_default_yaml() -> None: + """默认 yaml 里的紫色占位值原样加载(这一组待真实素材标定)。""" + cfg = load_tracking_config() + assert cfg.pan_source.hsv_lower == [125, 60, 60] + assert cfg.pan_source.hsv_upper == [165, 255, 255] + assert cfg.pan_source.min_area == 2000 + assert cfg.pan_source.min_detected_frame_fraction == 0.9 + # 顶部文字带同绿框那条:紫色轮廓和绿框画在同一画面上。 + assert cfg.pan_source.ignore_regions == cfg.prompt_source.ignore_regions + + +@pytest.mark.parametrize("bound", ["hsv_lower", "hsv_upper"]) +def test_pan_source_hsv_must_have_three_components(bound: str) -> None: + """HSV 阈值分量数不对即报错(否则要到 cv2.inRange 里才炸)。""" + with pytest.raises(ValidationError): + _validate({"pan_source": {bound: [125, 60]}}) + + +def test_pan_source_hsv_upper_must_not_be_below_lower() -> None: + """上下界写反即报错——inRange 不会报错,只会恒返回空掩膜,静默变成「检不到紫色」。""" + with pytest.raises(ValidationError): + _validate({"pan_source": {"hsv_lower": [125, 60, 60], "hsv_upper": [120, 255, 255]}}) + + +def test_pan_source_min_area_must_be_positive() -> None: + """min_area 须 ≥1。""" + with pytest.raises(ValidationError): + _validate({"pan_source": {"min_area": 0}}) + + +@pytest.mark.parametrize("fraction", [0.0, -0.1, 1.5]) +def test_pan_source_detected_fraction_out_of_bounds_rejected(fraction: float) -> None: + """检出帧占比须 ∈ (0, 1]:取 0 这道门形同虚设,>1 则永远判不出有效。""" + with pytest.raises(ValidationError): + _validate({"pan_source": {"min_detected_frame_fraction": fraction}}) + + +def test_pan_source_detected_fraction_accepts_one() -> None: + """1.0 合法(虽然默认不取它——一帧漏检就整段作废太脆)。""" + cfg = _validate({"pan_source": {"min_detected_frame_fraction": 1.0}}) + assert cfg.pan_source.min_detected_frame_fraction == 1.0 + + +# ---------- 任务书 02:配置不设默认值,删掉的旧字段不再接受 ---------- + + +def _leaf_paths(node: dict[str, Any], prefix: tuple[str, ...]) -> list[tuple[str, ...]]: + paths: list[tuple[str, ...]] = [] + for key, value in node.items(): + if isinstance(value, dict): + paths.extend(_leaf_paths(value, (*prefix, key))) # pyright: ignore[reportUnknownArgumentType] + paths.append((*prefix, key)) + return paths + + +@pytest.mark.parametrize("path", _leaf_paths(load_ceus_section(), ()), ids="/".join) +def test_every_field_is_required(path: tuple[str, ...]) -> None: + """ceus 段删掉任何一个键(含整个子段)都报错:没有一个字段有默认值。""" + section = load_ceus_section() + node = section + for key in path[:-1]: + node = node[key] + del node[path[-1]] + with pytest.raises(ValidationError): + TrackingConfig.model_validate(section) + + +@pytest.mark.parametrize( + "payload", + [ + {"views": {"source_view": "left"}}, + {"views": {"target_view": "right"}}, + {"views": {"effective_region": {"type": "full"}}}, + {"mapping": {"type": "normalized_view_coordinates", "anchor_points": []}}, + ], +) +def test_removed_fields_are_rejected(payload: dict[str, Any]) -> None: + """造影在哪一半、裁黑边、映射锚点这几个旧字段已删,写了就报错。""" + with pytest.raises(ValidationError): + _validate(payload) + + +def test_config_is_frozen() -> None: + cfg = load_tracking_config() + with pytest.raises(ValidationError): + cfg.keyframes.iou_threshold = 0.5 # pyright: ignore[reportAttributeAccessIssue] diff --git a/tests/test_ceus_contrast_side.py b/tests/test_ceus_contrast_side.py new file mode 100644 index 0000000..d88f143 --- /dev/null +++ b/tests/test_ceus_contrast_side.py @@ -0,0 +1,93 @@ +"""造影画面在哪一半:两半各自选提示帧,恰好一半有才定(任务书 02 第一阶段第 2 条)。 + +合成 400×200 的分屏视频,分屏线在 x=200;绿框是 120×100 的实心框(外接框面积 12000, +大于 config/ceus.yaml 的 min_area 10000),画在顶部文字带之下。预期值按画框位置手算。 +""" + +import cv2 +import numpy as np +import pytest + +from cadx.ceus import ( + CeusCaseError, + CeusConfig, + ContrastSideError, + ViewRect, + locate_contrast_side, + map_source_mask_to_target, + split_views, +) +from cadx.ceus._geometry import Frame + +_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200 +_GREEN = (0, 255, 0) + + +def _frames(green_boxes_x1: list[int]) -> list[Frame]: + """10 帧分屏画面;每个 x1 画一个整帧坐标 [x1, x1+119] × [60, 159] 的绿框。""" + frame = np.zeros((_HEIGHT, _WIDTH, 3), dtype=np.uint8) + frame[:, :_SPLIT] = 120 + frame[:, _SPLIT + 1 :] = 90 + for x1 in green_boxes_x1: + cv2.rectangle(frame, (x1, 60), (x1 + 119, 159), _GREEN, thickness=-1) + return [frame.copy() for _ in range(10)] + + +def test_green_box_in_left_half(ceus_config: CeusConfig) -> None: + frames = _frames([40]) + _, left, right = split_views(frames, ceus_config.views) + assert (left, right) == (ViewRect(0, 0, 200, 200), ViewRect(200, 0, 400, 200)) + + side = locate_contrast_side( + frames, left, right, ceus_config.prompt_source, ceus_config.keyframes + ) + + assert side.side == "left" + assert side.contrast_view == left + assert side.target_view == right + # 10 帧绿框不变:只有第一帧是提示帧。 + assert [p.frame_index for p in side.prompt_frames] == [0] + x1, y1, _, _ = side.prompt_frames[0].bbox_xyxy + assert abs(x1 - 40) <= 3 and abs(y1 - 60) <= 3 + + +def test_green_box_in_right_half(ceus_config: CeusConfig) -> None: + frames = _frames([250]) + _, left, right = split_views(frames, ceus_config.views) + + side = locate_contrast_side( + frames, left, right, ceus_config.prompt_source, ceus_config.keyframes + ) + + assert side.side == "right" + assert side.contrast_view == right + assert side.target_view == left + # 整帧 x=250 在右半局部是 250 − 200 = 50。 + prompt = side.prompt_frames[0] + assert abs(prompt.bbox_xyxy[0] - 50) <= 3 + # 挪到左半(灰阶那一半):整帧 x = 250 − 200 = 50,局部也是 50。 + mapped = map_source_mask_to_target(prompt.mask, side.contrast_view, side.target_view) + xs = np.where(mapped)[1] + assert abs(int(xs.min()) - 50) <= 3 + assert abs(int(xs.max()) - 169) <= 3 + + +def test_green_box_in_both_halves_fails_case(ceus_config: CeusConfig) -> None: + frames = _frames([40, 250]) + _, left, right = split_views(frames, ceus_config.views) + + with pytest.raises(ContrastSideError, match="两半都找到绿框") as raised: + locate_contrast_side(frames, left, right, ceus_config.prompt_source, ceus_config.keyframes) + + assert isinstance(raised.value, CeusCaseError) + assert raised.value.step == 2 + + +def test_no_green_box_fails_case(ceus_config: CeusConfig) -> None: + frames = _frames([]) + _, left, right = split_views(frames, ceus_config.views) + + with pytest.raises(ContrastSideError, match="两半都没有找到绿框") as raised: + locate_contrast_side(frames, left, right, ceus_config.prompt_source, ceus_config.keyframes) + + assert raised.value.step == 2 diff --git a/tests/test_ceus_geometry.py b/tests/test_ceus_geometry.py new file mode 100644 index 0000000..3b9fbd2 --- /dev/null +++ b/tests/test_ceus_geometry.py @@ -0,0 +1,278 @@ +"""B1 geometry 纯函数库单元测试。 + +钉死几何/重采样/平滑工具的关键性质:IoU 边界、等弧长重采样闭合性、 +三角平滑的恒等与偶窗拒绝、奇窗化与可选正整数解析、多边形栅格化 dtype, +以及 ViewRect 的不变量校验。测试用真实数据的二次构造(合成 mask / 正方形轮廓 / +常量轨迹),边缘场景靠实现里的 assertion/raise 保证。 +""" + +import numpy as np +import pytest + +from cadx.ceus import ( + ViewRect, + crop_frame, + local_rect_within, + mask_iou, + odd_window, + optional_positive_int, + polygon_area, + polygon_center, + polygon_to_mask, + polygons_to_mask, + rect_from_values, + resample_closed_contour, + triangular_smooth, +) + + +# Phase 1: mask_iou —— 交并比边界 +def test_mask_iou_identical_is_one() -> None: + mask = np.zeros((10, 10), dtype=bool) + mask[2:6, 3:7] = True + assert mask_iou(mask, mask) == 1.0 + + +def test_mask_iou_disjoint_is_zero() -> None: + a = np.zeros((10, 10), dtype=bool) + a[0:3, 0:3] = True + b = np.zeros((10, 10), dtype=bool) + b[6:9, 6:9] = True + assert mask_iou(a, b) == 0.0 + + +def test_mask_iou_empty_union_is_zero() -> None: + empty = np.zeros((5, 5), dtype=bool) + assert mask_iou(empty, empty) == 0.0 + + +def test_mask_iou_partial_overlap() -> None: + a = np.zeros((10, 10), dtype=bool) + a[0:4, 0:4] = True # 16 像素 + b = np.zeros((10, 10), dtype=bool) + b[2:6, 2:6] = True # 16 像素,交集 2x2=4,并集 28 + assert mask_iou(a, b) == pytest.approx(4 / 28) + + +# Phase 2: resample_closed_contour —— 等弧长重采样 +def _unit_square() -> np.ndarray: + return np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32) + + +def test_resample_shape_is_num_points_by_two() -> None: + out = resample_closed_contour(_unit_square(), 64) + assert out is not None + assert out.shape == (64, 2) + + +def test_resample_equal_arc_length_spacing() -> None: + out = resample_closed_contour(_unit_square(), 64) + assert out is not None + closed = np.vstack([out, out[0]]) + seg = np.sqrt((np.diff(closed, axis=0) ** 2).sum(axis=1)) + # 等弧长:所有相邻采样段长应近似一致 + assert seg.std() < 1e-3 + + +def test_resample_too_few_points_returns_none() -> None: + assert resample_closed_contour(np.array([[0.0, 0.0], [1.0, 1.0]]), 64) is None + assert resample_closed_contour(None, 64) is None + + +# Phase 3: triangular_smooth —— 时间轴三角加权 +def test_triangular_smooth_even_window_raises() -> None: + values = np.zeros((5, 4, 2), dtype=np.float32) + with pytest.raises(ValueError): + triangular_smooth(values, 4) + + +def test_triangular_smooth_window_one_is_identity() -> None: + rng = np.random.default_rng(0) + values = rng.standard_normal((6, 8, 2)).astype(np.float32) + out = triangular_smooth(values, 1) + assert np.array_equal(out, values) + + +def test_triangular_smooth_constant_track_unchanged() -> None: + const = np.full((7, 5, 2), 3.5, dtype=np.float32) + out = triangular_smooth(const, 3) + assert np.allclose(out, 3.5) + + +# Phase 4: odd_window —— 奇窗化 +def test_odd_window_even_increments() -> None: + assert odd_window(10) == 11 + + +def test_odd_window_odd_unchanged() -> None: + assert odd_window(7) == 7 + + +def test_odd_window_floor_at_one() -> None: + assert odd_window(0) == 1 + assert odd_window(-2) == 1 + + +# Phase 5: optional_positive_int —— 可选正整数解析 +def test_optional_positive_int_auto_is_none() -> None: + assert optional_positive_int("auto") is None + + +def test_optional_positive_int_none_is_none() -> None: + assert optional_positive_int(None) is None + + +def test_optional_positive_int_negative_is_none() -> None: + assert optional_positive_int(-3) is None + assert optional_positive_int(0) is None + + +def test_optional_positive_int_positive_parsed() -> None: + assert optional_positive_int(5) == 5 + assert optional_positive_int("8") == 8 + + +# Phase 6: polygon_to_mask —— 栅格化 +def test_polygon_to_mask_dtype_is_bool() -> None: + poly = _unit_square() + mask = polygon_to_mask(poly, 12, 12) + assert mask.dtype == bool + assert mask.shape == (12, 12) + assert mask.any() + + +# Phase 6a: polygons_to_mask —— even-odd 一次填充挖洞 +def test_polygons_to_mask_carves_hole_via_even_odd() -> None: + """外框 + 内洞一次 fillPoly:洞中心为 False、外框环上为 True(even-odd 挖洞)。""" + outer = np.array([[5.0, 5.0], [45.0, 5.0], [45.0, 45.0], [5.0, 45.0]]) + hole = np.array([[20.0, 20.0], [30.0, 20.0], [30.0, 30.0], [20.0, 30.0]]) + mask = polygons_to_mask([outer, hole], 50, 50) + assert mask.dtype == bool + assert not mask[25, 25] # 洞中心:被挖空 + assert mask[10, 10] # 外框环上:填充 + # 甜甜圈面积 = 外框 40x40 - 洞 10x10(栅格边界差几像素,放宽) + assert mask.sum() == pytest.approx(40 * 40 - 10 * 10, abs=200) + + +def test_polygons_to_mask_empty_is_all_false() -> None: + """空列表 / 全退化多边形:返回全 False 掩膜(不报错)。""" + assert not polygons_to_mask([], 20, 20).any() + degenerate = np.array([[1.0, 1.0], [2.0, 2.0]]) # < 3 顶点,跳过 + assert not polygons_to_mask([degenerate], 20, 20).any() + + +# Phase 6b: polygon_area —— 鞋带公式精确面积 +def test_polygon_area_square_exact() -> None: + """边长 10 的正方形面积精确为 100.0(鞋带浮点精确,不取整)。""" + square = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32) + assert polygon_area(square) == pytest.approx(100.0, abs=1e-6) + + +def test_polygon_area_triangle_exact() -> None: + """直角三角形(底 8 高 6)面积精确为 24.0。""" + triangle = np.array([[0.0, 0.0], [8.0, 0.0], [0.0, 6.0]], dtype=np.float32) + assert polygon_area(triangle) == pytest.approx(24.0, abs=1e-6) + + +def test_polygon_area_orientation_independent() -> None: + """顺时针 / 逆时针同一多边形面积一致(取绝对值消朝向符号)。""" + ccw = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32) + cw = ccw[::-1].copy() + assert polygon_area(ccw) == pytest.approx(polygon_area(cw), abs=1e-6) + assert polygon_area(cw) == pytest.approx(100.0, abs=1e-6) + + +def test_polygon_area_non_integer_vertices_not_quantized() -> None: + """半整数顶点不被取整:边长 10.5 正方形面积 = 110.25(不会被 round 量化)。""" + square = np.array([[0.0, 0.0], [10.5, 0.0], [10.5, 10.5], [0.0, 10.5]], dtype=np.float32) + assert polygon_area(square) == pytest.approx(110.25, abs=1e-4) + + +# Phase 6c: polygon_center —— 质心(退化时回退顶点均值) +def test_polygon_center_square_is_geometric_center() -> None: + """正方形质心在几何中心 (5, 5)。""" + square = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32) + center = polygon_center(square) + assert center == pytest.approx(np.array([5.0, 5.0]), abs=1e-3) + + +def test_polygon_center_rectangle_is_geometric_center() -> None: + """矩形质心在几何中心 (10, 3)。""" + rect = np.array([[0.0, 0.0], [20.0, 0.0], [20.0, 6.0], [0.0, 6.0]], dtype=np.float32) + center = polygon_center(rect) + assert center == pytest.approx(np.array([10.0, 3.0]), abs=1e-3) + + +def test_polygon_center_degenerate_collinear_falls_back_to_vertex_mean() -> None: + """共线顶点 m00≈0:退化路径回退到顶点算术均值,不崩、不除零。""" + collinear = np.array([[0.0, 0.0], [2.0, 2.0], [4.0, 4.0], [6.0, 6.0]], dtype=np.float32) + center = polygon_center(collinear) + assert center == pytest.approx(collinear.mean(axis=0), abs=1e-3) + + +def test_polygon_center_degenerate_coincident_falls_back_to_vertex_mean() -> None: + """所有点重合 m00≈0:退化路径回退到该点本身(顶点均值),不除零。""" + coincident = np.full((5, 2), 7.0, dtype=np.float32) + center = polygon_center(coincident) + assert center == pytest.approx(np.array([7.0, 7.0]), abs=1e-3) + + +# Phase 7: ViewRect / crop_frame / local_rect_within / rect_from_values +def test_view_rect_valid() -> None: + rect = ViewRect(0, 0, 4, 6) + assert (rect.x1, rect.y1, rect.x2, rect.y2) == (0, 0, 4, 6) + + +def test_view_rect_rejects_inverted() -> None: + with pytest.raises(ValueError): + ViewRect(5, 0, 3, 6) + with pytest.raises(ValueError): + ViewRect(0, 6, 4, 2) + with pytest.raises(ValueError): + ViewRect(-1, 0, 4, 6) + + +def test_crop_frame_slices_subimage() -> None: + frame = np.arange(100).reshape(10, 10) + rect = ViewRect(2, 1, 5, 4) + sub = crop_frame(frame, rect) + assert sub.shape == (3, 3) + assert np.array_equal(sub, frame[1:4, 2:5]) + + +def test_local_rect_within_translates_to_parent_origin() -> None: + parent = ViewRect(10, 20, 110, 220) + child = ViewRect(30, 50, 60, 90) + local = local_rect_within(parent, child) + assert (local.x1, local.y1, local.x2, local.y2) == (20, 30, 50, 70) + + +def test_rect_from_values_pixel() -> None: + rect = rect_from_values([3, 4, 8, 9], width=20, height=20) + assert (rect.x1, rect.y1, rect.x2, rect.y2) == (3, 4, 8, 9) + + +def test_rect_from_values_normalized() -> None: + rect = rect_from_values([0.0, 0.0, 0.5, 0.5], width=100, height=80) + assert (rect.x1, rect.y1, rect.x2, rect.y2) == (0, 0, 50, 40) + + +def test_rect_from_values_clamps_and_keeps_min_extent() -> None: + # x2==x1 时应被推到 x1+1,避免退化 + rect = rect_from_values([5, 5, 5, 5], width=20, height=20) + assert rect.x2 >= rect.x1 + 1 + assert rect.y2 >= rect.y1 + 1 + + +def test_rect_from_values_wrong_length_raises() -> None: + with pytest.raises(ValueError): + rect_from_values([1, 2, 3], width=10, height=10) + + +def test_viewrect_width_height(): + """ViewRect.width/height 与参考接口对齐,供下游消费。""" + from cadx.ceus import ViewRect + + r = ViewRect(10, 20, 110, 220) + assert r.width == 100 + assert r.height == 200 diff --git a/tests/test_ceus_green_prompt.py b/tests/test_ceus_green_prompt.py new file mode 100644 index 0000000..019fc95 --- /dev/null +++ b/tests/test_ceus_green_prompt.py @@ -0,0 +1,291 @@ +"""B3 green_prompt 绿框提示检测单元测试。 + +钉死四件事的关键性质(参考 ``references/ROI/roi_tracking_pipeline.py`` P:241-332/989-1004): + +- ``extract_color_prompt``:合成在已知位置画绿色多边形的 BGR 左视图帧 → + 返回该绿框 bbox(含端点 ``x2=x1+w-1`` / ``y2=y1+h-1``)+ 充满轮廓的布尔 mask; + 纯灰度帧(无绿)→ ``None``。 +- ``ignore_regions``:把绿框画在顶部文字带内,配置抹除顶部带后该绿不计 → ``None``; + 绿框在带外则正常返回。 +- ``min_area``:按 **bbox 面积 w×h**(findings §3.2,非轮廓面积)过滤—— + bbox 面积 < min_area 的候选被丢弃。 +- ``collect_prompt_observations``:对多帧序列返回逐帧观测列表, + 无绿帧以 ``None`` 占位(``observation.prompt is None``)。 +- ``prompt_color_mask``:TIC 端"排除绿框色"掩膜——``exclude_prompt_color=False`` 全 False; + ``prompt_exclude_dilate=1`` 不膨胀(mask == 原始 inRange);增大膨胀核则 mask 更大。 + +测试用真实数据的二次构造(合成定位绿框 / 纯灰度帧),边缘场景靠实现 raise/None 保证。 +""" + +import cv2 +import numpy as np + +from cadx.ceus import ( + CeusConfig, + ColorPrompt, + PanSourceConfig, + PromptObservation, + PromptSourceConfig, + TicAnalysisConfig, + collect_prompt_observations, + extract_color_prompt, + prompt_color_mask, +) +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) + + +# 纯绿(BGR):H≈60、S=255、V=255,落在 inRange [35,60,60]~[90,255,255] 内。 +_GREEN_BGR = (0, 255, 0) +_GRAY_BGR = 120 +# 紫(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。 +_PURPLE_BGR = (255, 0, 212) + + +def _gray_frame(height: int, width: int) -> np.ndarray: + """合成一帧纯灰度 BGR 图(三通道等值),不含任何绿色像素。""" + return np.full((height, width, 3), _GRAY_BGR, dtype=np.uint8) + + +def _frame_with_green_rect( + height: int, + width: int, + rect_xyxy: tuple[int, int, int, int], + *, + thickness: int = -1, +) -> np.ndarray: + """在灰底帧上画一个绿色矩形(默认实心填充)。 + + ``rect_xyxy`` 为 ``(x1, y1, x2, y2)`` 含端点像素坐标。 + """ + frame = _gray_frame(height, width) + x1, y1, x2, y2 = rect_xyxy + cv2.rectangle(frame, (x1, y1), (x2, y2), _GREEN_BGR, thickness=thickness) + return frame + + +def _cfg(**overrides: object) -> PromptSourceConfig: + """构造 PromptSourceConfig,默认无 ignore_regions、min_area=1,便于钉死定位。""" + base: dict[str, object] = {"ignore_regions": [], "min_area": 1} + base.update(overrides) + return _CONFIG.prompt_source.model_copy(update=base) + + +def _tic_cfg(**overrides: object) -> TicAnalysisConfig: + """构造 TicAnalysisConfig,默认 exclude_prompt_color=True、prompt_exclude_dilate=1。 + + 把 dilate 默认置 1 便于钉死"原始 inRange == mask"(无膨胀外扩), + 各测试再按需覆盖以验证条件膨胀语义。 + """ + base: dict[str, object] = {"prompt_exclude_dilate": 1} + base.update(overrides) + return _CONFIG.tic_analysis.model_copy(update=base) + + +def _pan_cfg() -> PanSourceConfig: + """紫圈检测配置:config/ceus.yaml 的取值,去掉忽略区、min_area=1。""" + return _CONFIG.pan_source.model_copy(update={"ignore_regions": [], "min_area": 1}) + + +# Phase 1: extract_color_prompt —— 定位绿框 / 无绿返回 None +def test_extract_returns_bbox_with_inclusive_endpoints() -> None: + # 在 (40,30)-(120,90) 画实心绿框(含端点)。 + rect = (40, 30, 120, 90) + frame = _frame_with_green_rect(120, 200, rect) + prompt = extract_color_prompt(frame, _cfg()) + assert isinstance(prompt, ColorPrompt) + x1, y1, x2, y2 = prompt.bbox_xyxy + # 形态学 CLOSE+dilate 会外扩几像素,允许 ±3 容差,钉死端点语义。 + assert abs(x1 - 40) <= 3 and abs(y1 - 30) <= 3 + assert abs(x2 - 120) <= 3 and abs(y2 - 90) <= 3 + # 含端点:width = x2 - x1 + 1。 + assert prompt.width == x2 - x1 + 1 + assert prompt.height == y2 - y1 + 1 + # mask 是布尔、与帧同高宽、绿框区域为 True。 + assert prompt.mask.dtype == np.bool_ + assert prompt.mask.shape == (120, 200) + assert bool(prompt.mask[60, 80]) # 框中心点在 mask 内 + assert prompt.candidate_count >= 1 + + +def test_extract_gray_frame_returns_none() -> None: + assert extract_color_prompt(_gray_frame(120, 200), _cfg()) is None + + +# Phase 2: ignore_regions —— 顶部文字带抹除 +def test_ignore_regions_suppresses_top_band_green() -> None: + # 绿框完全落在顶部 8.34% 带内(120*0.0834≈10px),抹除后不计。 + top_rect = (40, 1, 120, 8) + frame = _frame_with_green_rect(120, 200, top_rect) + cfg = _cfg(ignore_regions=[[0.0, 0.0, 1.0, 0.0834]]) + assert extract_color_prompt(frame, cfg) is None + + +def test_ignore_regions_keeps_below_band_green() -> None: + # 绿框在顶部带之下,抹除顶部带不影响它。 + body_rect = (40, 40, 120, 90) + frame = _frame_with_green_rect(120, 200, body_rect) + cfg = _cfg(ignore_regions=[[0.0, 0.0, 1.0, 0.0834]]) + prompt = extract_color_prompt(frame, cfg) + assert prompt is not None + assert abs(prompt.bbox_xyxy[1] - 40) <= 3 + + +# Phase 3: min_area —— 按 bbox 面积 w×h 过滤 +def test_min_area_filters_by_bbox_area() -> None: + # 绿框 bbox≈ 21×21=441;min_area=2000 应过滤掉 → None。 + small_rect = (50, 50, 70, 70) + frame = _frame_with_green_rect(120, 200, small_rect) + assert extract_color_prompt(frame, _cfg(min_area=2000)) is None + # 放宽 min_area 后能检出。 + assert extract_color_prompt(frame, _cfg(min_area=100)) is not None + + +def test_min_area_boundary_equal_passes() -> None: + # 钉死边界:bbox 面积"恰等于"min_area 应通过(实现是 `< min_area` 过滤, + # 非 `<= min_area`;与参考 P:270 一致)。用细绿框确保 bbox 面积可精确预测, + # 不被形态学外扩干扰(CLOSE+dilate 只让外轮廓更大、bbox 不会缩小)。 + # 实心绿框 (50,50)-(70,70):bbox = (70-50+1)*(70-50+1) = 21*21 = 441。 + rect = (50, 50, 70, 70) + expected_area = (70 - 50 + 1) * (70 - 50 + 1) + assert expected_area == 441 + frame = _frame_with_green_rect(120, 200, rect) + # 形态学会外扩,故实际 bbox 面积 >= 441;取一个明显小于实际面积、 + # 但用于证伪 `<=` 误写的下界即可——这里用恰等于"检出框的真实面积"。 + detected = extract_color_prompt(frame, _cfg(min_area=1)) + assert detected is not None + area_eq = detected.bbox_area + # min_area 恰等于检出 bbox 面积 → 应通过(area >= min_area); + # 若误写成 `<= min_area` 过滤,则此处会被丢弃返回 None,从而证伪。 + on_boundary = extract_color_prompt(frame, _cfg(min_area=area_eq)) + assert on_boundary is not None + assert on_boundary.bbox_area == area_eq + # 反向:min_area 比真实面积大 1 → 被过滤为 None(确认过滤确实生效)。 + assert extract_color_prompt(frame, _cfg(min_area=area_eq + 1)) is None + + +def test_hollow_green_rect_closes_to_single_filled_contour() -> None: + # 覆盖参考 P:261-288 真实服务路径:医生画的是空心绿框(细描边,非实心)。 + # 用 thickness=3 的空心矩形,验证 CLOSE+dilate 后 findContours 连通成单个外轮廓, + # 且 drawContours 实心填充使 mask 覆盖框内区域(含框中心,原图中心是灰底非绿)。 + rect = (40, 30, 140, 100) + frame = _frame_with_green_rect(120, 200, rect, thickness=3) + # 前置:中心点在原图是灰底(空心框内部无绿),证明确为空心框。 + cx, cy = (40 + 140) // 2, (30 + 100) // 2 + assert tuple(int(c) for c in frame[cy, cx]) == (_GRAY_BGR, _GRAY_BGR, _GRAY_BGR) + prompt = extract_color_prompt(frame, _cfg(min_area=100)) + assert prompt is not None + # 单个外轮廓:候选数为 1(空心框经形态学连通后只剩一条外轮廓)。 + assert prompt.candidate_count == 1 + # 实心填充 mask 覆盖框内中心区域(findContours 外轮廓 + drawContours thickness=-1)。 + assert bool(prompt.mask[cy, cx]) + # bbox 端点贴合空心框外缘(±5 容差吸收核 (5,5) CLOSE+dilate 的外扩)。 + assert abs(prompt.bbox_xyxy[0] - 40) <= 5 and abs(prompt.bbox_xyxy[1] - 30) <= 5 + assert abs(prompt.bbox_xyxy[2] - 140) <= 5 and abs(prompt.bbox_xyxy[3] - 100) <= 5 + + +def test_min_area_picks_largest_bbox_candidate() -> None: + # 两个绿框:小框 (10,10)-(30,30),大框 (80,60)-(170,110);选大框。 + frame = _frame_with_green_rect(120, 200, (10, 10, 30, 30)) + cv2.rectangle(frame, (80, 60), (170, 110), _GREEN_BGR, thickness=-1) + prompt = extract_color_prompt(frame, _cfg(min_area=1)) + assert prompt is not None + assert prompt.candidate_count == 2 + # 选中的应是大框(左上角接近 80,60)。 + assert abs(prompt.bbox_xyxy[0] - 80) <= 3 + assert abs(prompt.bbox_xyxy[1] - 60) <= 3 + + +# Phase 4: collect_prompt_observations —— 多帧观测列表(None 占位) +def test_collect_observations_none_placeholder_for_no_green_frames() -> None: + frames = [ + _frame_with_green_rect(120, 200, (40, 40, 120, 90)), # 有绿 + _gray_frame(120, 200), # 无绿 → None 占位 + _frame_with_green_rect(120, 200, (50, 40, 130, 95)), # 有绿 + ] + observations = collect_prompt_observations(frames, _cfg()) + assert len(observations) == 3 + assert all(isinstance(o, PromptObservation) for o in observations) + assert [o.frame_index for o in observations] == [0, 1, 2] + assert observations[0].prompt is not None + assert observations[1].prompt is None # 无绿帧占位 + assert observations[2].prompt is not None + assert observations[1].detected is False + assert observations[0].detected is True + + +# Phase 5: prompt_color_mask —— TIC 端"排除绿框色"掩膜(受 tic_cfg 门控) +def test_prompt_color_mask_marks_green_pixels() -> None: + frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) + mask = prompt_color_mask(frame, _cfg(), _tic_cfg()) + assert mask.dtype == np.bool_ + assert mask.shape == (120, 200) + assert bool(mask[60, 80]) # 绿框内为 True + assert not bool(mask[5, 5]) # 灰底为 False + + +def test_prompt_color_mask_all_gray_is_empty() -> None: + mask = prompt_color_mask(_gray_frame(120, 200), _cfg(), _tic_cfg()) + assert not mask.any() + + +def test_prompt_color_mask_exclude_disabled_returns_all_false() -> None: + # exclude_prompt_color=False → 直接返回全 False(语义:"不排除任何绿框色像素")。 + frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) + mask = prompt_color_mask(frame, _cfg(), _tic_cfg(exclude_prompt_color=False)) + assert mask.dtype == np.bool_ + assert mask.shape == (120, 200) + assert not mask.any() + + +def test_prompt_color_mask_dilate_one_equals_raw_inrange() -> None: + # prompt_exclude_dilate=1 → 不膨胀,mask 应恰等于原始 inRange 二值。 + frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) + mask = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=1)) + lower = np.asarray(_cfg().hsv_lower, dtype=np.uint8) + upper = np.asarray(_cfg().hsv_upper, dtype=np.uint8) + hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) + raw = cv2.inRange(hsv, lower, upper) > 0 + assert np.array_equal(mask, raw) + + +def test_prompt_color_mask_larger_dilate_grows_mask() -> None: + # prompt_exclude_dilate=5 应比 =1 时膨胀出更多 True 像素(外扩绿框边缘)。 + frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) + mask_one = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=1)) + mask_five = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=5)) + assert int(mask_five.sum()) > int(mask_one.sum()) + # 膨胀只增不减:dilate=1 的 True 像素应全被 dilate=5 覆盖。 + assert bool((mask_five | mask_one == mask_five).all()) + + +# Phase 5: 换一组颜色范围就是另一个检测器(design/0030 决策四) +def test_extract_color_prompt_detects_purple_with_pan_config() -> None: + """同一个 ``extract_color_prompt``,喂 ``PanSourceConfig`` 检的是紫色轮廓。 + + 紫色轮廓是医生画的邻近正常胰腺实质(``ROI_pan``),和绿框走同一套 HSV 机制、 + 同一个函数——所以这里断言的是「函数认颜色配置,不认死绿色」这个行为。 + """ + rect = (40, 30, 120, 90) + frame = _gray_frame(120, 200) + cv2.rectangle(frame, rect[:2], rect[2:], _PURPLE_BGR, thickness=-1) + + pan_cfg = _pan_cfg() + prompt = extract_color_prompt(frame, pan_cfg) + assert isinstance(prompt, ColorPrompt) + assert bool(prompt.mask[60, 80]) + # 同一帧喂绿框那组配置一个候选都没有——两组颜色范围互不命中。 + assert extract_color_prompt(frame, _cfg()) is None + + +def test_prompt_color_mask_takes_hsv_from_the_config_it_is_given() -> None: + """排除色掩膜的 HSV 来源是入参:喂紫色配置就出紫色掩膜,喂绿框配置全空。""" + frame = _gray_frame(120, 200) + cv2.rectangle(frame, (40, 30), (120, 90), _PURPLE_BGR, thickness=-1) + + purple_mask = prompt_color_mask(frame, _pan_cfg(), _tic_cfg()) + green_mask = prompt_color_mask(frame, _cfg(), _tic_cfg()) + assert bool(purple_mask[60, 80]) + assert not green_mask.any() diff --git a/tests/test_ceus_mapping.py b/tests/test_ceus_mapping.py new file mode 100644 index 0000000..375d1c6 --- /dev/null +++ b/tests/test_ceus_mapping.py @@ -0,0 +1,179 @@ +"""两半画面之间的坐标换算:平移量 = 目标视图左上角 − 造影视图左上角(任务书 02 第一阶段第 1 条)。 + +后两条照搬旧 test_ceus_mapping(去掉锚点配置参数,断言不变);其余是新写的手算测试。 +旧文件里依赖锚点、有效区或缩放的用例不再成立,已删(见任务书 02 执行报告)。 +""" + +import cv2 +import numpy as np +import pytest + +from cadx.ceus import ( + CeusConfig, + ViewRect, + map_source_mask_to_target, + polygon_to_mask, + split_views, + target_polygons_to_source, + view_offset, +) +from conftest import load_ceus_section + + +def _largest_external_contour(mask: np.ndarray) -> np.ndarray: + """从二值 mask 取面积最大的外轮廓,返回 ``(N, 2)`` 的 ``float32`` 顶点序列。 + + 参数: + mask: ``dtype=bool`` 的二值 mask。 + + 返回: + 最大外轮廓顶点 ``(x, y)``(``float32``);用于真往返中把 target mask 还原成多边形喂逆向。 + """ + contours, _ = cv2.findContours( + mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + assert contours, "target mask 应非空,才能提取外轮廓做逆向往返。" + largest = max(contours, key=cv2.contourArea) + return largest.reshape(-1, 2).astype(np.float32) + + +def _bbox(points: np.ndarray) -> tuple[float, float, float, float]: + """返回顶点序列的包围盒 ``(x_min, x_max, y_min, y_max)``(往返形状比对用)。""" + return ( + float(points[:, 0].min()), + float(points[:, 0].max()), + float(points[:, 1].min()), + float(points[:, 1].max()), + ) + + +def test_map_source_mask_to_target_translates_into_target_effective() -> None: + """source 视图局部 mask 经映射落入 target 有效区局部坐标,形状为 target_eff 尺寸。""" + # source view = 整个左半区 [0,100)×[0,100);有效区同 view(无黑边)。 + source_view = ViewRect(0, 0, 100, 100) + target_eff = ViewRect(100, 0, 200, 100) + + source_mask = np.zeros((100, 100), dtype=bool) + source_mask[30:50, 20:40] = True # source 局部一个方块 + + target_mask = map_source_mask_to_target(source_mask, source_view, target_eff) + + assert target_mask.dtype == bool + assert target_mask.shape == (target_eff.height, target_eff.width) + # 同尺寸有效区 + 无偏移:target 局部方块应与 source 局部方块同位置。 + ys, xs = np.where(target_mask) + assert int(xs.min()) == 20 and int(xs.max()) == 39 + assert int(ys.min()) == 30 and int(ys.max()) == 49 + + +def test_real_roundtrip_same_size_no_offset_restores_polygon() -> None: + """真往返(同尺寸有效区、无 offset):多边形→栅格化→正向→提轮廓→逆向,近似还原。 + + 不再假造 target 多边形:从正向 ``map_source_mask_to_target`` 的真实输出 mask 里 + 用 ``cv2.findContours`` 提最大外轮廓喂逆向,端到端串起正向(mask)→逆向(polygon)。 + 栅格化 + 轮廓提取带量化误差,容差放宽到 ~2px,但 assert 包围盒近似还原。 + """ + source_view = ViewRect(0, 0, 120, 100) + target_eff = ViewRect(120, 0, 240, 100) + + # 已知 source 视图局部多边形(远离边界,避免 clip 干扰往返)。 + source_polygon = np.array( + [[20.0, 30.0], [60.0, 25.0], [70.0, 70.0], [25.0, 65.0]], dtype=np.float32 + ) + + # 正向:source 局部多边形 → 栅格化 mask → 映射到 target 有效区局部 mask。 + source_mask = polygon_to_mask(source_polygon, source_view.height, source_view.width) + target_mask = map_source_mask_to_target(source_mask, source_view, target_eff) + assert target_mask.sum() > 0 + + # 从真实 target mask 提最大外轮廓得 target 多边形,再逆向映回 source 视图局部。 + target_polygon = _largest_external_contour(target_mask) + [back_to_source] = target_polygons_to_source([target_polygon], source_view, target_eff) + + # 形状/包围盒近似还原(栅格化+轮廓量化误差 ≤2px)。 + np.testing.assert_allclose(_bbox(back_to_source), _bbox(source_polygon), atol=2.0) + + +def _seam_frames(width: int, height: int, split_x: int) -> list[np.ndarray]: + """合成几帧左右分屏画面:两半是不同的常量灰度,`split_x` 处一列纯黑缝。""" + frame = np.zeros((height, width, 3), dtype=np.uint8) + frame[:, :split_x] = 180 + frame[:, split_x + 1 :] = 120 + return [frame.copy() for _ in range(5)] + + +# 三种宽度(40 例里有 5 种),分屏线在正中:右半左上角 x = W/2,平移量手算为 (W/2, 0)。 +@pytest.mark.parametrize( + ("width", "expected_dx"), + [(1856, 928), (1280, 640), (1024, 512)], +) +def test_view_offset_is_difference_of_view_top_left_corners(width: int, expected_dx: int) -> None: + config = CeusConfig.model_validate(load_ceus_section()) + _, left, right = split_views(_seam_frames(width, 200, expected_dx), config.views) + assert view_offset(left, right) == (expected_dx, 0) + assert view_offset(right, left) == (-expected_dx, 0) + + +@pytest.mark.parametrize( + ("width", "split_x", "box_x1", "expected_target_x1"), + [ + # 造影在左半:整帧 x=300 的框挪到右半,整帧 x = 300 + 928 = 1228。 + (1856, 928, 300, 1228), + # 造影在左半:整帧 x=300 → 300 + 640 = 940。 + (1280, 640, 300, 940), + # 造影在左半:整帧 x=100 → 100 + 512 = 612。 + (1024, 512, 100, 612), + ], +) +def test_mask_moves_by_view_offset_in_frame_coordinates( + width: int, split_x: int, box_x1: int, expected_target_x1: int +) -> None: + left = ViewRect(0, 0, split_x, 200) + right = ViewRect(split_x, 0, width, 200) + source_mask = np.zeros((left.height, left.width), dtype=bool) + source_mask[50:80, box_x1 - left.x1 : box_x1 - left.x1 + 40] = True + + target_mask = map_source_mask_to_target(source_mask, left, right) + + ys, xs = np.where(target_mask) + assert int(xs.min()) + right.x1 == expected_target_x1 + assert int(xs.max()) + right.x1 == expected_target_x1 + 39 + assert (int(ys.min()), int(ys.max())) == (50, 79) + + +def test_mask_moves_from_right_half_to_left_half() -> None: + """造影在右半:1856 宽、分屏线 928,整帧 x=1000 的框挪到左半,整帧 x = 1000 − 928 = 72。""" + left = ViewRect(0, 0, 928, 200) + right = ViewRect(928, 0, 1856, 200) + source_mask = np.zeros((right.height, right.width), dtype=bool) + source_mask[10:20, 1000 - 928 : 1040 - 928] = True + + target_mask = map_source_mask_to_target(source_mask, right, left) + + ys, xs = np.where(target_mask) + assert (int(xs.min()), int(xs.max())) == (72, 111) + assert (int(ys.min()), int(ys.max())) == (10, 19) + + +def test_polygons_move_back_by_negative_view_offset() -> None: + """右半局部 (40, 30) 的点挪回左半:整帧 (968, 30) − 928 = (40, 30);超出左半的点裁到边界。""" + left = ViewRect(0, 0, 928, 200) + right = ViewRect(928, 0, 1856, 200) + polygon = np.array([[40.0, 30.0], [60.0, 30.0], [927.0, 250.0]], dtype=np.float32) + + [back] = target_polygons_to_source([polygon], left, right) + + np.testing.assert_allclose(back, [[40.0, 30.0], [60.0, 30.0], [927.0, 199.0]]) + + +def test_mask_crops_when_target_half_is_narrower() -> None: + """宽度为奇数时右半比左半窄 1 像素:不缩放,超出右半的那一列裁掉。""" + left = ViewRect(0, 0, 101, 50) + right = ViewRect(101, 0, 201, 50) + source_mask = np.zeros((50, 101), dtype=bool) + source_mask[:, 99:101] = True + + target_mask = map_source_mask_to_target(source_mask, left, right) + + assert target_mask.shape == (50, 100) + assert np.where(target_mask)[1].tolist() == [99] * 50 diff --git a/tests/test_ceus_prompt_frames.py b/tests/test_ceus_prompt_frames.py new file mode 100644 index 0000000..e1e2d6e --- /dev/null +++ b/tests/test_ceus_prompt_frames.py @@ -0,0 +1,138 @@ +"""B4 关键帧选择单元测试。 + +钉死 findings §3.3 / 参考 P:335-372 的关键帧语义:首帧强制(change_iou=1.0)、 +形状不变不新增、``change_iou < iou_threshold``(严格小于)才记新 prompt_frame、 +``min_gap_frames`` 在 IoU 判定之前抑制过密、无绿帧跳过、返回结构字段齐全。 + +测试用真实算法路径(``mask_iou``)而非 mock:构造形状渐变的布尔 mask 绿框观测序列, +让 IoU 在阈值上下精确落点(含恰好等于阈值的边界帧)。 +""" + +import numpy as np + +from cadx.ceus import ( + CeusConfig, + ColorPrompt, + KeyframesConfig, + PromptFrame, + PromptObservation, + select_prompt_frames, +) +from conftest import load_ceus_section + + +def _keyframes_cfg(**overrides: object) -> KeyframesConfig: + """提示帧配置:config/ceus.yaml 的取值(旧测试用的是同值的默认值),按需改某一项。""" + config = CeusConfig.model_validate(load_ceus_section()) + return config.keyframes.model_copy(update=overrides) + + +def _square_prompt(side: int, canvas: int = 100) -> ColorPrompt: + """构造一个以原点为左上角、边长 ``side`` 的实心方块绿框 ``ColorPrompt``。 + + mask 为 ``(canvas, canvas)`` 布尔图,``[0:side, 0:side]`` 为 True; + 其余几何字段按方块几何自洽填充,仅 mask 参与关键帧 IoU 判定。 + """ + mask = np.zeros((canvas, canvas), dtype=bool) + mask[0:side, 0:side] = True + contour = np.array( + [[[0, 0]], [[side - 1, 0]], [[side - 1, side - 1]], [[0, side - 1]]], + dtype=np.int32, + ) + return ColorPrompt( + mask=mask, + contour=contour, + bbox_xyxy=(0, 0, side - 1, side - 1), + bbox_area=side * side, + contour_area=float((side - 1) * (side - 1)), + candidate_count=1, + ) + + +def _obs(frame_index: int, side: int | None) -> PromptObservation: + """构造单帧观测:``side`` 为 None 表示无绿帧(prompt=None)。""" + prompt = None if side is None else _square_prompt(side) + return PromptObservation(frame_index=frame_index, prompt=prompt) + + +def test_first_detected_frame_is_forced_prompt_frame_with_change_iou_one() -> None: + """首个有绿框的观测必为 prompt_frame,change_iou 恒记 1.0。""" + observations = [_obs(0, 40)] + prompt_frames = select_prompt_frames(observations, _keyframes_cfg()) + assert len(prompt_frames) == 1 + assert prompt_frames[0].frame_index == 0 + assert prompt_frames[0].change_iou == 1.0 + + +def test_unchanged_shape_does_not_add_prompt_frame() -> None: + """绿框形状完全不变(IoU=1.0 ≥ 阈值)的连续帧不新增 prompt_frame。""" + observations = [_obs(i, 40) for i in range(5)] + prompt_frames = select_prompt_frames(observations, _keyframes_cfg()) + assert len(prompt_frames) == 1 + assert prompt_frames[0].frame_index == 0 + + +def test_iou_below_threshold_records_new_prompt_frame() -> None: + """IoU 跌破阈值的帧记为新 prompt_frame,change_iou 等于实测 IoU。 + + 边长 40→20 的方块:IoU = 20² / 40² = 0.25 < 0.92。 + """ + observations = [_obs(0, 40), _obs(1, 20)] + prompt_frames = select_prompt_frames(observations, _keyframes_cfg()) + assert [kf.frame_index for kf in prompt_frames] == [0, 1] + assert prompt_frames[1].change_iou == 0.25 + + +def test_iou_equal_to_threshold_does_not_record_boundary() -> None: + """恰好 IoU == 阈值不记新 prompt_frame(严格 ``<``,钉死 < 非 <=)。 + + 取 iou_threshold = IoU(40, 20) = 0.25。第二帧 IoU 恰等于 0.25, + ``0.25 < 0.25`` 为 False,故不新增。 + """ + config = _keyframes_cfg(iou_threshold=0.25) + observations = [_obs(0, 40), _obs(1, 20)] + prompt_frames = select_prompt_frames(observations, config) + assert [kf.frame_index for kf in prompt_frames] == [0] + + +def test_min_gap_frames_suppresses_dense_prompt_frames() -> None: + """``min_gap_frames`` 在 IoU 判定前门控,抑制过密 prompt_frame。 + + 三帧 40→20→40:默认 min_gap=1 时帧 1、帧 2 都会被记(IoU 均 0.25<0.92); + 设 min_gap=2 后,帧 1 距首帧仅 1 < 2 被跳过,帧 2 距首帧 2 不小于 2 才比较, + 其 IoU(40,40)=1.0 ≥ 0.92 故也不记,最终仅保留首帧。 + """ + observations = [_obs(0, 40), _obs(1, 20), _obs(2, 40)] + + dense = select_prompt_frames(observations, _keyframes_cfg()) + assert [kf.frame_index for kf in dense] == [0, 1, 2] + + sparse = select_prompt_frames(observations, _keyframes_cfg(min_gap_frames=2)) + assert [kf.frame_index for kf in sparse] == [0] + + +def test_no_green_frames_are_skipped() -> None: + """无绿帧(prompt=None)跳过,不占用首帧强制位也不影响 frame_index 对齐。""" + observations = [_obs(0, None), _obs(1, None), _obs(2, 40), _obs(3, 20)] + prompt_frames = select_prompt_frames(observations, _keyframes_cfg()) + assert [kf.frame_index for kf in prompt_frames] == [2, 3] + # 首个有绿帧(frame 2)才是强制 prompt_frame,change_iou=1.0 + assert prompt_frames[0].change_iou == 1.0 + + +def test_prompt_frame_result_structure_fields() -> None: + """返回项含 frame_index / bbox_xyxy / change_iou,并保留几何溯源字段。""" + observations = [_obs(7, 40)] + prompt_frames = select_prompt_frames(observations, _keyframes_cfg()) + prompt_frame = prompt_frames[0] + assert isinstance(prompt_frame, PromptFrame) + assert prompt_frame.frame_index == 7 + assert prompt_frame.bbox_xyxy == (0, 0, 39, 39) + assert prompt_frame.change_iou == 1.0 + assert prompt_frame.bbox_area == 1600 + assert prompt_frame.mask.dtype == bool + + +def test_empty_observations_returns_empty() -> None: + """空观测序列返回空 prompt_frame 列表(无绿帧可选)。""" + assert select_prompt_frames([], _keyframes_cfg()) == [] diff --git a/tests/test_ceus_split_screen.py b/tests/test_ceus_split_screen.py new file mode 100644 index 0000000..2f057bc --- /dev/null +++ b/tests/test_ceus_split_screen.py @@ -0,0 +1,130 @@ +"""B2 split_screen 分屏检测单元测试(旧文件的黑边剔除部分随裁黑边功能删去)。 + +钉死两件事的关键性质: +- ``detect_split_x``:合成左右两半(中间一列黑缝)→ 切分线落在中缝 ±2px; + 全黑 / 无缝 / 切分偏出中部合理范围 → ``SplitDetectionError``(不兜底)。 +- ``view_rect``:horizontal_split 用检测到的 ``split_x`` 而非 ``W//2``; + vertical_split / full / manual_crop 分支与参考一致;非法组合 ``RuntimeError``。 + +测试用真实数据的二次构造(合成左右分屏帧),边缘场景靠实现 raise 保证。 +""" + +import numpy as np +import pytest + +from cadx.ceus import ( + SplitDetectionError, + ViewRect, + detect_split_x, + view_rect, +) +from conftest import load_ceus_section + +# 旧 detect_split_x 的参数默认值;新代码不设默认值,从 config/ceus.yaml 读。 +_LO, _HI = load_ceus_section()["views"]["split_search_band"] +BAND = (float(_LO), float(_HI)) + + +def _split_frame( + height: int, + width: int, + split_x: int, + *, + left_gray: int = 180, + right_gray: int = 120, +) -> np.ndarray: + """合成一帧左右分屏 BGR 图:左右两半各为常量灰度,``split_x`` 处一列纯黑缝。""" + frame = np.zeros((height, width, 3), dtype=np.uint8) + frame[:, :split_x] = left_gray + frame[:, split_x + 1 :] = right_gray + frame[:, split_x] = 0 + return frame + + +# Phase 1: detect_split_x —— 中缝定位与防御 +def test_detect_split_x_finds_centered_seam() -> None: + frames = [_split_frame(120, 200, 100) for _ in range(5)] + detected = detect_split_x(frames, search_band=BAND) + assert abs(detected - 100) <= 2 + + +def test_detect_split_x_finds_offcenter_seam_within_band() -> None: + # 缝在 0.42*W 处,仍落在 band=(0.35,0.65) 内、左右宽度比合法。 + frames = [_split_frame(120, 200, 84) for _ in range(5)] + detected = detect_split_x(frames, search_band=BAND) + assert abs(detected - 84) <= 2 + + +def test_detect_split_x_all_black_raises() -> None: + frames = [np.zeros((120, 200, 3), dtype=np.uint8) for _ in range(3)] + with pytest.raises(SplitDetectionError): + detect_split_x(frames, search_band=BAND) + + +def test_detect_split_x_no_seam_raises() -> None: + # 整幅均匀灰度、无暗缝:中部 band 内列均值平坦,找不到稳定低谷。 + frames = [np.full((120, 200, 3), 150, dtype=np.uint8) for _ in range(3)] + with pytest.raises(SplitDetectionError): + detect_split_x(frames, search_band=BAND) + + +def test_detect_split_x_empty_frames_raises() -> None: + with pytest.raises(SplitDetectionError): + detect_split_x([], search_band=BAND) + + +def test_detect_split_x_reversed_band_raises() -> None: + # 逆序 band(lo>=hi)非法:要求 0<=lo None: + # 越界 band(hi>1)非法。 + frames = [_split_frame(120, 200, 100) for _ in range(3)] + with pytest.raises(SplitDetectionError): + detect_split_x(frames, search_band=(0.35, 1.5)) + + +def test_detect_split_x_width_ratio_violation_raises() -> None: + # 把 band 放宽到几乎整宽,缝置于极偏位置(仍在 band 内),使左右宽度比越界。 + # split_x=20、W=200 → 左 20 / 右 180,比值 0.11 < 0.5 → SplitDetectionError。 + frames = [_split_frame(120, 200, 20) for _ in range(3)] + with pytest.raises(SplitDetectionError): + detect_split_x(frames, search_band=(0.05, 0.95)) + + +# Phase 2: view_rect —— horizontal_split 用 split_x,而非 W//2 +def test_view_rect_horizontal_split_uses_split_x() -> None: + width, height, split_x = 200, 120, 84 + left = view_rect({"layout": "horizontal_split"}, "left", width, height, split_x=split_x) + right = view_rect({"layout": "horizontal_split"}, "right", width, height, split_x=split_x) + assert left == ViewRect(0, 0, split_x, height) + assert right == ViewRect(split_x, 0, width, height) + # 与朴素 W//2 不同,证明用了检测值。 + assert split_x != width // 2 + + +def test_view_rect_vertical_split() -> None: + width, height = 200, 120 + top = view_rect({"layout": "vertical_split"}, "top", width, height, split_x=None) + bottom = view_rect({"layout": "vertical_split"}, "bottom", width, height, split_x=None) + assert top == ViewRect(0, 0, width, height // 2) + assert bottom == ViewRect(0, height // 2, width, height) + + +def test_view_rect_full() -> None: + rect = view_rect({"layout": "full"}, "left", 200, 120, split_x=None) + assert rect == ViewRect(0, 0, 200, 120) + + +def test_view_rect_manual_crop() -> None: + cfg = {"layout": "manual_crop", "crops": {"left": [0.0, 0.0, 0.5, 1.0]}} + rect = view_rect(cfg, "left", 200, 120, split_x=None) + assert rect == ViewRect(0, 0, 100, 120) + + +def test_view_rect_unsupported_pair_raises() -> None: + with pytest.raises(RuntimeError): + view_rect({"layout": "horizontal_split"}, "top", 200, 120, split_x=100) diff --git a/tests/test_ceus_video.py b/tests/test_ceus_video.py new file mode 100644 index 0000000..e273dcc --- /dev/null +++ b/tests/test_ceus_video.py @@ -0,0 +1,154 @@ +"""VideoFile 单元测试:probe / iter_frames / frame_times_s / 打不开报错。 + +旧测试读一段真实样例视频的字节;这里在测试里用 cv2 现造一段几十帧的小视频,按路径读。 +旧版先把字节落临时文件,那三条「临时文件有没有删」的测试随之不再适用,已删。 +""" + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from cadx.ceus import VideoFile + +_FRAMES = 40 +_WIDTH, _HEIGHT = 64, 48 + + +@pytest.fixture(scope="module") +def sample_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + """用 cv2 现造一段 40 帧、64×48、25 fps 的 mp4,每帧亮度不同。""" + path = tmp_path_factory.mktemp("video") / "sample.mp4" + writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), 25.0, (_WIDTH, _HEIGHT)) + for index in range(_FRAMES): + frame = np.full((_HEIGHT, _WIDTH, 3), (index * 6) % 256, dtype=np.uint8) + frame[index % _HEIGHT, :, 2] = 255 + writer.write(frame) + writer.release() + return path + + +def test_probe_returns_positive_metadata(sample_path: Path) -> None: + """probe 返回 (fps, frame_count, width, height):字段顺序/类型/语义范围均须正确。""" + video = VideoFile(sample_path) + result = video.probe() + assert isinstance(result, tuple) and len(result) == 4 + fps, frame_count, width, height = result + # 字段类型契约(app/ports.py 的 DecodedVideo):fps 为 float,其余为 int + assert isinstance(fps, float) + assert isinstance(frame_count, int) + assert isinstance(width, int) + assert isinstance(height, int) + # 语义合理范围——并钉死字段顺序未颠倒(fps 是小帧率、尺寸是较大像素值) + assert 0 < fps < 1000 # 帧率不会是几百几千;若与 frame_count(881) 颠倒会触发 + assert frame_count > 0 + assert width > 1 and height > 1 + + +def test_probe_field_order_matches_iter(sample_path: Path) -> None: + """probe 的 (width, height) 须与 iter_frames 实际帧的 (W, H) 一致(防字段顺序颠倒)。""" + video = VideoFile(sample_path) + _fps, _frame_count, width, height = video.probe() + _idx, frame = next(video.iter_frames()) + assert frame.shape == (height, width, 3) + + +def test_iter_frames_indices_contiguous_and_count_matches_probe( + sample_path: Path, +) -> None: + """iter_frames 产 (idx, frame):idx 从 0 连续递增,且帧数 == probe 的 frame_count。""" + video = VideoFile(sample_path) + _, frame_count, _, _ = video.probe() + indices = [idx for idx, _frame in video.iter_frames()] + + assert indices == list(range(len(indices))) + assert len(indices) == frame_count + + +def test_iter_frames_yields_bgr_uint8_hwc(sample_path: Path) -> None: + """每帧为 (H, W, 3) uint8 的 ndarray,且 H/W 与 probe 一致。""" + video = VideoFile(sample_path) + _, _, width, height = video.probe() + idx, frame = next(video.iter_frames()) + + assert idx == 0 + assert isinstance(frame, np.ndarray) + assert frame.dtype == np.uint8 + assert frame.shape == (height, width, 3) + + +def test_frame_times_are_strictly_increasing_and_match_frame_count( + sample_path: Path, +) -> None: + video = VideoFile(sample_path) + _fps, frame_count, _width, _height = video.probe() + times = video.frame_times_s() + + assert times.shape == (frame_count,) + assert times[0] >= 0.0 + assert np.all(np.diff(times) > 0.0) + + +def test_解两遍逐帧像素完全相同(sample_path: Path) -> None: + """**这条是接缝改成「打开一次」的全部理由**(design/0024)。 + + CEUS 那条线要分几趟过同一段视频,每趟只留这一趟的小结果。第二趟必须从第 0 帧重新开始, + 而且看到的像素要和第一趟一模一样——多趟方案整个建立在这个前提上。它今天成立是因为 + 每趟各开一个 VideoCapture 顺序读同一个文件,但**这件事此前没有任何一处验证过**。 + + **比的是全片,而且是流式比。** 只比前几帧的话,可变帧率或者 seek 行为导致的漂移多半 + 出现在后面而不是开头。两趟各自逐帧过、逐帧喂进哈希,峰值内存是两帧——**这条测试自己 + 也不许把视频攒起来**,它守的正是这件事。 + """ + import hashlib + + def _digest(video: VideoFile) -> tuple[str, int]: + h = hashlib.blake2b(digest_size=16) + count = 0 + for _, frame in video.iter_frames(): + h.update(np.ascontiguousarray(frame).tobytes()) + count += 1 + return h.hexdigest(), count + + video = VideoFile(sample_path) + first = _digest(video) + second = _digest(video) + + assert first[1] > 0 + assert first == second, "同一段视频解两遍,逐帧像素必须完全相同" + + +def test_两趟各自从第零帧开始互不干扰(sample_path: Path) -> None: + """两趟同时在飞时各读各的游标。 + + **断言比的是像素,不是 iter_frames 自己报的下标**——那个下标是本模块里一个 Python + 计数器,就算内层真把外层的读位置搅乱了,它照样从 0 数上去。 + """ + video = VideoFile(sample_path) + outer = video.iter_frames() + _, outer_frame_0 = next(outer) + # 内层整个跑起来,中途丢掉——它不该动到外层的游标 + inner = video.iter_frames() + _, inner_frame_0 = next(inner) + _, inner_frame_1 = next(inner) + del inner + _, outer_frame_1 = next(outer) + + reference = [frame for _, frame in zip(video.iter_frames(), range(2), strict=False)] + + assert np.array_equal(inner_frame_0, outer_frame_0), "内层没有从第 0 帧开始" + assert np.array_equal(outer_frame_1, inner_frame_1), "外层被内层带跑了:它接着读到的不是第 1 帧" + assert len(reference) == 2 + + +def test_bad_bytes_raise_not_silent(tmp_path: Path) -> None: + """坏文件无法打开 → 报错(不静默返回空 probe / 空迭代器)。""" + garbage = tmp_path / "garbage.mp4" + garbage.write_bytes(b"this is not a video file" * 64) + video = VideoFile(garbage) + + with pytest.raises(RuntimeError): + video.probe() + with pytest.raises(RuntimeError): + list(video.iter_frames()) diff --git a/uv.lock b/uv.lock index 5c1cf1e..63542b3 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,8 @@ name = "cadx" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, { name = "openpyxl" }, { name = "pillow" }, { name = "pydantic" }, @@ -35,6 +37,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "numpy", specifier = ">=2.5.4" }, + { name = "opencv-python-headless", specifier = ">=5.0.0.93" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "pydantic", specifier = ">=2.14.1" }, @@ -249,6 +253,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, + { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, + { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5"