feat(ceus): 任务书 02 第一阶段——底层和分割之前的算法;修复 C14

任务书 01 遗留修复(C14):
- library 新增 check_key_image(path):关键图能逐像素保真重编码时返回 None,否则返回原因;
  _write_clean_png 的判断移过去,自身保留断言性质报错(走到这里说明 build 漏了检查)。
- build 写库前对每例调用,返回原因的整例跳过,记 C14「关键图能原样重编码」,涉及值写原因。
- 原来三条「关键图格式不对整次构建停下」的测试改为整例跳过,另加非 PNG、library 漏检报错两条。
- 结构.md 第八部分、guides/装配两个库.md 相应几行改掉。

第一阶段(src/cadx/ceus/):
- 照搬旧 geometry、video_decoder、research_config、split_screen、green_prompt、prompt_frames、
  mapping,去掉产品层,注释改中文。
- 配置类去掉全部默认值,strict、extra=forbid、frozen;新建 config/ceus.yaml(paths、ceus 两段),
  数值照抄旧 yaml,逐项注明出处。删去 source_view/target_view、effective_region、mapping 锚点;
  旧代码写死的分屏抽帧数 25 和搜索区间 (0.35, 0.65) 改成配置,值不变。
- 视频解码改为按路径用 cv2 逐帧读。
- 坐标换算改为两个视图矩形左上角之差,不再用按某种宽度写死的锚点。
- 造影画面在哪一半:两半各自选提示帧,恰好一半有才定,否则抛 ContrastSideError(CeusCaseError
  子类,带步号和原因,供第三阶段记 failed.csv)。
- 旧测试照搬(改 import 与配置读取方式),因上述改动不再成立的删去;新增两处修改的手算测试。
- import-linter 加「ceus 不引用其他 cadx 模块」。依赖加 numpy、opencv-python-headless。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 03:46:56 -04:00
parent de1d6b2d51
commit 9a916c65a5
29 changed files with 3634 additions and 45 deletions
+5 -3
View File
@@ -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 例的医生标注工作簿(含造影剂到达时间和注射时间)。
+155
View File
@@ -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
+12
View File
@@ -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"
+16 -4
View File
@@ -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-<git 短哈希>`,工作区不干净时末尾加 `-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`,相对路径按仓库根目录解析。
+5 -4
View File
@@ -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 透明色;否则重编码会悄悄降位或丢透明。涉及值写不过的原因 | 跳过这一例 |
几点说明:
+40 -9
View File
@@ -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),
+113
View File
@@ -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",
]
+533
View File
@@ -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
+76
View File
@@ -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("左右两半都没有找到绿框,选不出提示帧")
+15
View File
@@ -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
+202
View File
@@ -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<x2 且 0<=y1<y2,得到 "
f"({self.x1}, {self.y1}, {self.x2}, {self.y2})。"
)
@property
def width(self) -> 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
+186
View File
@@ -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)
]
+67
View File
@@ -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
+78
View File
@@ -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
+186
View File
@@ -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<hi<=1,收到 {search_band}。")
column_means = _mean_gray(frames).mean(axis=0)
width = int(column_means.shape[0])
band_lo = round(lo_ratio * width)
band_hi = round(hi_ratio * width)
if band_hi - band_lo < 1:
raise SplitDetectionError(
f"search_band 在宽度 {width} 下退化为空区间 [{band_lo}, {band_hi})。"
)
band_means = column_means[band_lo:band_hi]
split_x = band_lo + int(np.argmin(band_means))
band_median = float(np.median(band_means))
valley_value = float(column_means[split_x])
if valley_value >= 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
+90
View File
@@ -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)
+2 -1
View File
@@ -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",
+22 -12
View File
@@ -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")
+27
View File
@@ -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())
+47 -10
View File
@@ -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)
_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"):
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:
+396
View File
@@ -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]
+93
View File
@@ -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
+278
View File
@@ -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
+291
View File
@@ -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()
+179
View File
@@ -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
+138
View File
@@ -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()) == []
+130
View File
@@ -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<hi<=1,应直接报错而非兜底。
frames = [_split_frame(120, 200, 100) for _ in range(3)]
with pytest.raises(SplitDetectionError):
detect_split_x(frames, search_band=(0.6, 0.4))
def test_detect_split_x_out_of_range_band_raises() -> 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)
+154
View File
@@ -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())
Generated
+96
View File
@@ -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"