diff --git a/config/ceus.yaml b/config/ceus.yaml index cdc4eab..c995b47 100644 --- a/config/ceus.yaml +++ b/config/ceus.yaml @@ -1,7 +1,8 @@ # CEUS 定量的配置,给 scripts/ceus_quant.py 用。 # # 分三段:paths(位置)、segment(分割设置,任务书 02 第二阶段加)、ceus(算法参数)。 -# ceus 段的字段定义和校验在 src/cadx/ceus/_config.py;paths 段和最外层由 scripts/ceus_quant.py 定义。 +# ceus 段的字段定义和校验在 src/cadx/ceus/_config.py,segment 段在 src/cadx/segment/_config.py; +# paths 段和最外层由 scripts/ceus_quant.py 定义。路径都相对仓库根目录。 # 每个字段必填,可以没有的值写 null。 # # ceus 段的数值逐个照抄旧项目 config/ceus_roi_default.yaml,没有改动(待定问题/02:参数何时冻结)。 @@ -17,6 +18,14 @@ paths: runs_root: runs/ceus # 每次运行一个子目录,只追加不删 work_root: work # 可删掉重算的中间文件(分割缓存) +segment: # 分割设置,字段定义在 src/cadx/segment/_config.py。出自旧项目 debugs/medsam2_local_track.py + medsam2_dir: reference/MedSAM2 # MedSAM2 代码,提交号从它的 .git 读。旧脚本是远程 /home/wangwenhan/MedSAM2 + checkpoint: models/MedSAM2_latest.pt # 权重。旧脚本是 MedSAM2/checkpoints/MedSAM2_latest.pt + sam2_config: configs/sam2.1_hiera_t512.yaml # 模型配置的 Hydra 名(相对 sam2 包)。照旧脚本 DEFAULT_MEDSAM2_CFG + device: cuda # 照旧脚本 DEFAULT_DEVICE + offload_video_to_cpu: false # 照旧脚本:t1_local_debug.py 调它时没开这个开关 + offload_state_to_cpu: false # 同上 + ceus: config_version: "4" # 参数口径版本号,由人改。出自旧项目:Lognormal-v2 参数口径(design/0044) @@ -65,6 +74,12 @@ ceus: 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 + # 下面四项旧 schema 里没有,旧 dark_region.py 用 getattr(..., 默认值) 读,这里照旧值写出来。 + # 旧项目工程侧定的,无出处 + max_regions: null # 每帧最多保留几块暗区,null 不限 + max_contours_per_region: null # 每块暗区最多取几条外轮廓,null 不限 + preferred_area_ratio_range: null # 暗区占 ROI 面积的偏好区间 [lo, hi],null 时面积分偏好占一半 + prompt_exclude_dilate: 3 # 剔绿框色前对绿色像素的膨胀核边长(与 tic_analysis 那个是两个独立的数) preserve_holes: true # 出自旧项目 findings §3.8 fill_holes: false # 出自旧项目 findings §3.8 morphology_kernel: 5 # 出自旧项目 findings §3.8 @@ -129,9 +144,11 @@ ceus: 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 决策二 + anchor_frames: # 7 张关键帧定位(第 8 步)。出自旧项目 design/0040 决策二 # 下面这批数旧项目注明镜像自 Linear《临床口径契约》(2026-08-25 版,同步日期 2026-09-01), - # 动任何一个之前先去那边核对。各容差窗口的出处在第二阶段迁移关键帧模块时逐个再核对。 + # 动任何一个之前先去那边核对。容差窗口的出处任务书 02 第二阶段逐个核对过(旧 yaml 注释、 + # 旧 research-wiki/explanation/tic-quantification-encoding.md 第 8、9 节、 + # 旧 research-wiki/reference/glossary.md「锚点帧」两张表、旧 design/0030 的「已定的事」表)。 arrival: # 到达判据,门槛取两个候选的高者。出自旧项目:《临床口径契约》5.2 baseline_sd_multiplier: 3.0 # 候选一:基线抖动 ×3.0。出自旧项目:《临床口径契约》5.2 relative_peak_threshold: 0.10 # 候选二:峰高 ×0.10。出自旧项目:《临床口径契约》5.2 @@ -139,17 +156,26 @@ ceus: # 主峰突出度门槛:基线抖动 ×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 中较宽的一档。无外部出处 + baseline_frame_backoff_s: 0.3 # B0 锚点 = 到达时刻再往前 0.3 秒。出自旧项目:《临床口径契约》5.11(临床方原规范 §13.2) + # D1 窗 [锚点, 锚点+0.5 秒](不许取到达之前)。出自旧项目:《临床口径契约》第 12 题四个容差之一, + # 对应临床方原规范 §13.3 + d1_window_after_s: 0.5 + d2_window_s: 0.3 # D2 窗 ±0.3 秒。出自旧项目:《临床口径契约》第 12 题,临床方原规范 §13.4 + d3_window_s: 0.5 # D3 窗 ±0.5 秒。出自旧项目:《临床口径契约》第 12 题,临床方原规范 §13.5 + # D4 窗 ±0.5 秒:临床方原规范 §13.6 只给约束(晚于主峰、不许用最后一帧)不给窗口,契约第 12 题 + # 委托工程侧。旧项目工程侧定,理由是 D4 与 D2/D3 同属曲线形状锚点,洗出段比洗入段平缓、导数 + # 极值定位误差更大,取 D2/D3 中较宽的一档。无外部出处 d4_window_s: 0.5 - b0_window_s: 0.5 # B0 窗 ±0.5 秒:契约空着,旧项目工程侧取与 D4 同档,无外部出处 + # B0 窗 ±0.5 秒:临床口径只说「视频开始到到达前 0.3 秒之间挑帧」,没给窗口。旧项目工程侧取与 D4 + # 同档,无外部出处 + b0_window_s: 0.5 late_phase: - preferred_time_s: 60.0 # F2 锚点:推注后 60 秒。出自旧项目:《临床口径契约》 - tolerance_s: 2.0 # F2 容差 ±2 秒。出自旧项目:《临床口径契约》第 12 题 + # F2 锚点:推注后 60 秒。出自旧项目:《临床口径契约》,临床方 2026-08-16 定(旧 glossary 记 HAI-75) + preferred_time_s: 60.0 + # F2 容差 ±2 秒(只属于 F2)。出自旧项目:《临床口径契约》第 12 题四个容差之四,临床方原规范 + # §10.1、§13.7 + tolerance_s: 2.0 # F1 锚点:推注后 20 秒。出自旧项目:《临床口径契约》(08-16 口头定)。 - # F1 没有容差键:旧项目工程侧暂定「无上限,取最近的一帧」,无出处 + # F1 没有容差键:旧项目工程侧暂定「无上限,在视频有效帧范围内取最近的一帧」(旧 + # tic-quantification-encoding.md 第 8 节),临床方没给容差,无外部出处 f1_anchor_s: 20.0 diff --git a/pyproject.toml b/pyproject.toml index a6e3477..0166b2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,15 @@ version = "0.0.0" description = "胰腺囊性肿瘤多模态会诊诊断的研究代码" requires-python = ">=3.12" dependencies = [ + "matplotlib>=3.11.2", + "medsam2", "numpy>=2.5.4", "opencv-python-headless>=5.0.0.93", "openpyxl>=3.1.5", "pillow>=12.3.0", "pydantic>=2.14.1", "pyyaml>=6.0.3", + "scipy>=1.18.1", ] [dependency-groups] @@ -23,6 +26,14 @@ dev = [ "types-openpyxl>=3.1.5.20260827", ] +[tool.uv] +# 只在本机和远程服务器(都是 Linux x86_64)上跑;只为这一种平台解析依赖, +# 免得 torch 的各平台 CUDA 轮子都要下载一遍。 +environments = ["sys_platform == 'linux' and platform_machine == 'x86_64'"] + +[tool.uv.sources] +medsam2 = { path = "reference/MedSAM2", editable = true } + [build-system] requires = ["hatchling>=1.26"] build-backend = "hatchling.build" @@ -50,7 +61,8 @@ pythonVersion = "3.12" typeCheckingMode = "strict" venvPath = "." venv = ".venv" -extraPaths = ["tests"] +# medsam2 以 setuptools 的导入钩子方式可编辑安装,pyright 跟不过去,直接指给它源码目录。 +extraPaths = ["tests", "reference/MedSAM2"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -69,12 +81,25 @@ root_packages = ["cadx"] include_external_packages = true [[tool.importlinter.contracts]] -# design 0001:torch 与 MedSAM2 只许出现在分割模块(模块落地时在这里放行 cadx.segment)。 -name = "torch 与 sam2 不进 cadx" +# design 0001:torch 与 MedSAM2 只许出现在分割模块。import-linter 不能写「除 segment 外的 cadx」 +# (只有 ignore_imports 能开例外),所以逐个列出其余模块;新模块落地时加进 source_modules。 +name = "cadx 里只有 segment 引用 torch、sam2" type = "forbidden" -source_modules = ["cadx"] +source_modules = [ + "cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", "cadx.ceus", +] forbidden_modules = ["torch", "sam2"] +[[tool.importlinter.contracts]] +# 结构.md 第三部分依赖表:segment 不引用其他 cadx 模块(它不认识分屏和绿框)。 +name = "segment 不引用其他 cadx 模块" +type = "forbidden" +source_modules = ["cadx.segment"] +forbidden_modules = [ + "cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", + "cadx.ceus", "cadx.llm", "cadx.ledger", "cadx.consult", "cadx.evaluate", "cadx.verdicts", +] + [[tool.importlinter.contracts]] # 结构.md 第三部分依赖表:外部层与格式层的模块不依赖其他 cadx 模块。 name = "delivery、library、answers、runs 互不依赖" diff --git a/research-wiki/explanation/结构.md b/research-wiki/explanation/结构.md index 6e4ed84..4bb098c 100644 --- a/research-wiki/explanation/结构.md +++ b/research-wiki/explanation/结构.md @@ -172,7 +172,7 @@ library/<构建号>/ | MR 报告「所见」段 | 看不见 | 看不见 | 看得见 | | 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 | -性别、年龄只给临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。 +性别、年龄在资料库里只放临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路的第一轮判断必须只凭影像证据,先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。这张表管的是资料库里每一路放什么。会诊时,影像两路在第一轮判断之后,需要时可以向临床专家提问,临床专家的回答可以含性别、年龄;这属于会诊流程,细节由会诊任务书定(负责人 2026-10-16)。 病例用化名,目录和清单按化名排序:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关;化名是按种子随机抽的 4 位小写字母加数字,按它排序后的顺序与交付顺序无关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。 @@ -263,9 +263,10 @@ 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/`**。下面只列契约实际强制的(共 7 条),没有的保护不写: +导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 8 条),没有的保护不写: -- `cadx` 不引用 `torch`、`sam2`。 +- `cadx` 里只有 segment 引用 `torch`、`sam2`(契约逐个列出其余已落地的模块,新模块落地时加进去)。 +- segment 不引用其他 `cadx` 模块。 - delivery、library、answers、runs 互不引用。 - delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。 - ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。 @@ -273,7 +274,7 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0 - 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。 - 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。 -第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 segment 引用 `torch`、`sam2`(落地时把上面第一条改成放行 segment);只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。 +第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。 契约管不到的,不要当成机器守住了: @@ -292,7 +293,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 | 模块 | 预期行数 | 实际行数 | 旧项目对应部分的行数 | | --- | --- | --- | --- | | ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | — | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 | -| segment | 150–300 | — | `medsam2_local_track.py` 240 | +| segment | 150–300 | 220 | `medsam2_local_track.py` 240 | | delivery | 400–700 | 733 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 | | library | 150–300 | 300 | `input_packages.py` 104 | | answers | 100–200 | 201 | — | @@ -365,15 +366,26 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 - `make_build_id(now, git)`:构建号 `YYYYMMDD-HHMMSS-`,工作区不干净时末尾加 `-dirty`。 - `write_snapshot(snapshot_dir, build_id, git, started_at, finished_at, uv_lock, configs)`:新建快照目录,复制配置原文并写 `snapshot.json`(构建号、git 状态、Python 版本、`uv.lock` 哈希、起止时间、各配置的哈希)。 -### ceus(`src/cadx/ceus/`,目前只有第 1、2 步和坐标换算) +### ceus(`src/cadx/ceus/`) +对外两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步: + +- `prepare_segmentation(video_path, cfg)`:第 1–2 步,交出 `SegmentationInput`;找不到可信分屏线抛 `SplitDetectionError`(第 1 步),两半都有或都没有绿框抛 `ContrastSideError`(第 2 步)。 +- `SegmentationInput`:视频的 fps、帧数、宽高、逐帧时间戳,分屏线,`contrast`(`ContrastSide`:哪一半是造影画面、造影视图和分割那一半视图 `target_view`、提示帧),`target_frames`(分割那一半的逐帧 BGR 图像),`prompt_masks`(提示帧帧号 → 绿框挪到 `target_view` 局部坐标的掩膜),造影那一半的第一帧。 +- `quantify_case(prepared, target_masks, injection_offset_s, cfg)`:第 4–9 步。`target_masks` 是分割在 `target_view` 局部坐标上的 `(帧数, 高, 宽)` 布尔数组,`injection_offset_s` 是推注时间 X;分割一帧有效轮廓都没有抛 `NoValidContourError`(第 4 步)。拟合没过质量门不抛错。 +- `QuantResult`:`trajectory`(`Trajectory`)、`tic`(`TicResult`)、`curves`(`TicCurves`)、推注时间、病灶两条曲线拟合是否都过质量门、`anchors`(7 个槽位的 `AnchorFrameLocation`)、`perfusion`(`roi_all`、`roi_perf` 各自的 `LognormalPerfusionMetrics`,拟合没过为 None)、紫圈检出率。 +- `AnchorFrameLocation`:一个关键帧槽位的帧号、视频时间、推注后时刻、状态(`ok` 或未定位的类别)、未定位的原因、锚点时刻。 +- 写产物用:`pack_trajectory(trajectory)`(trajectory.npz 字节)、`pack_tic_curves(curves)`(tic_curves.npz 字节)、`render_tic_png(tic, markers=tic_plot_markers(anchors))`(tic.png,标出 D1–D4)、`render_case_overlay(prepared, result)`(overlay.mp4 字节)、`keyframe_images(prepared, anchors)`(定位到的关键帧整幅原图,槽位 → 图像)、`quant_summary(result)`(拟合、灌注指标、紫圈检出率、7 个关键帧定位,可直接写成 JSON);读回用 `unpack_trajectory`、`unpack_tic_curves`。 - `CeusConfig` 及各段的配置类:`config/ceus.yaml` 里 `ceus` 段的字段定义(pydantic,字段必填、不许多写、读入后不可改);`paths` 段由 `scripts/ceus_quant.py` 定义。 +- `CeusCaseError`:只落在这一例上的失败,带失败的步号和原因;`SplitDetectionError`、`ContrastSideError`、`NoValidContourError` 是它的子类。脚本据此把这一例记进 `failed.csv`、接着跑下一例。 - `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` 等)是各步的组成部分,供第二阶段的模块和测试用。 +- 其余导出(几何函数、各步的算法函数与结果类型,如 `split_views`、`locate_contrast_side`、`masks_to_smoothed_polygons`、`extract_dark_region_polygons`、`select_reference_region`、`compute_tic_for_video`、`fit_lognormal_curve`、`locate_anchor_frames`、`compute_lognormal_perfusion_metrics`、`render_overlay`)是两段接口的组成部分,供测试用。 + +### segment(`src/cadx/segment/`) + +- `segment_video(frames, prompt_masks, cfg)`:把提示帧的病灶掩码用 MedSAM2 传播到每一帧;`frames` 是分割那一半的逐帧 BGR 图像,`prompt_masks` 是提示帧帧号 → 同尺寸布尔掩码,交出 `(帧数, 高, 宽)` 布尔数组(即 `quantify_case` 的 `target_masks`)。 +- `SegmentConfig`:`config/ceus.yaml` 里 `segment` 段的字段定义(MedSAM2 代码目录、权重、模型配置名、设备、两个 offload 开关;pydantic,字段必填、不许多写、读入后不可改)。 +- `medsam2_commit(medsam2_dir)`:读 MedSAM2 代码目录 `.git` 的 HEAD 提交号,给快照和分割缓存键用。 ### build(`src/cadx/build/`) diff --git a/research-wiki/guides/开发环境.md b/research-wiki/guides/开发环境.md index a0acd0f..cf727fc 100644 --- a/research-wiki/guides/开发环境.md +++ b/research-wiki/guides/开发环境.md @@ -32,6 +32,55 @@ uv run pre-commit install # 每次提交前跑 gitleaks、本地材料拦截 |---|---| | `data/` | 远程 `wangwenhan@202.38.247.58:CADx-4-CAD/data/` 下的 `交付包_20260908_40例`、`1.2-TEST`,用 rsync 同步 | | `reference/CADx-4-CAD/` | 旧项目:先 rsync 工作区(排除 `data/`),再 `git fetch` 远程 `CADx-4` 仓库的全部分支恢复历史 | +| `reference/MedSAM2/` | MedSAM2 代码(连同 `.git`),见下一节 | +| `models/` | MedSAM2 权重 `MedSAM2_latest.pt`,见下一节 | 远程服务器走 `202.38.247.58`,不走 Tailscale 地址 `100.83.164.94`:同一台机器,后者慢几百倍。 -| `models/` | MedSAM2 权重,CEUS 迁移的任务书落地后补充来源 | + +## 五、MedSAM2(CEUS 分割用) + +MedSAM2 是医学图像分割模型(GitHub `bowang-lab/MedSAM2`),CEUS 定量的第 3 步用它把几帧的病灶提示传播到整段视频。它以可编辑方式装进项目环境,代码放在 `reference/MedSAM2/`,`src/cadx/segment` 在同一进程里 `import sam2` 调用。位置写在 `config/ceus.yaml` 的 `segment` 段。 + +**1. 拷代码和权重**(两种情况都要做;代码和权重都不进 git): + +```bash +# 代码:连同 .git 一起拷(提交号从这个 .git 读),不要 checkpoints/ 和 data/,约 25MB +rsync -a --exclude checkpoints/ --exclude data/ \ + wangwenhan@202.38.247.58:/home/wangwenhan/MedSAM2/ reference/MedSAM2/ +# 权重:只要这一个文件,约 156MB +mkdir -p models +rsync -a wangwenhan@202.38.247.58:/home/wangwenhan/MedSAM2/checkpoints/MedSAM2_latest.pt models/ +``` + +远程的代码是 `bowang-lab/MedSAM2` 的 332f30d 提交,只改了 `download.sh`,不影响代码。 + +**2. 装进环境**,分两种情况: + +- **仓库里已有 `uv.lock` 且已含 `medsam2`**(`pyproject.toml` 的依赖里有 `medsam2`,平常就是这种):拷完代码后 `make sync`(即 `uv sync`)就会按锁文件装上 torch 和 MedSAM2。 +- **第一次把 MedSAM2 加进项目**(`pyproject.toml` 里还没有 `medsam2`): + + ```bash + SAM2_BUILD_CUDA=0 uv add --editable reference/MedSAM2 + ``` + + `SAM2_BUILD_CUDA=0` 表示不编译 MedSAM2 的 CUDA 扩展(旧项目也没用它)。命令会改 `pyproject.toml` 和 `uv.lock`。 + +`uv sync` 时同样要设 `SAM2_BUILD_CUDA=0`(`SAM2_BUILD_CUDA=0 uv sync`),否则重新构建 `medsam2` 时会尝试编译 CUDA 扩展。 + +**代理太慢时**:torch 的轮子有几个 GB,走代理可能很慢。可以临时绕开代理直连国内镜像: + +```bash +env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY \ + UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ + SAM2_BUILD_CUDA=0 uv sync # 第一次加进项目时把 uv sync 换成 uv add --editable reference/MedSAM2 +``` + +镜像地址只在这一条命令里临时用,不写进 `pyproject.toml`;`uv add` 会把镜像地址写进 `uv.lock` 里各个包的来源,装完要把这些地址换回 `https://pypi.org/simple`(以及对应的 `files.pythonhosted.org` 下载地址),再跑一次 `uv lock --check` 确认锁文件和 `pyproject.toml` 一致。 + +**3. 确认 torch 看得到显卡**: + +```bash +uv run python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))" +``` + +应打印 torch 版本、`True` 和显卡名。最后跑 `uv run pytest -m gpu -v`:在一段合成小视频上真跑一次 MedSAM2。运行时会出现几条警告(注意力内核回退;`sam2/_C.so` 里的连通域后处理报 `inputs must be a CUDA tensor` 后被跳过),MedSAM2 自己说明这不影响主要结果,测试仍应 `passed`。 diff --git a/src/cadx/ceus/__init__.py b/src/cadx/ceus/__init__.py index 2028972..485a2be 100644 --- a/src/cadx/ceus/__init__.py +++ b/src/cadx/ceus/__init__.py @@ -1,10 +1,23 @@ """ceus:CEUS 定量的全部算法和参数,纯计算,不引用其他 cadx 模块。 -目前有第 1、2 步(视频解码、分屏、找绿框、选提示帧、判断造影画面在哪一半)和两半之间的坐标换算; -第 3 步以后随任务书 02 第二阶段加入。 -某一例不满足算法前提时抛 `CeusCaseError`(带失败的步号和原因)。 +对外两段接口:`prepare_segmentation` 做第 1–2 步(解码、分屏、定造影那一半、选提示帧),交出分割的 +输入;`quantify_case` 拿分割结果做第 4–9 步(轮廓、暗区、背景圆、TIC、拟合、关键帧、灌注指标)。 +第 3 步分割由 segment 做,ceus 不引用它。某一例不满足算法前提时抛 `CeusCaseError` +(带失败的步号和原因)。 +写产物用:`pack_trajectory`、`pack_tic_curves`、`render_tic_png` + `tic_plot_markers`、 +`render_case_overlay`、`keyframe_images`、`quant_summary`。 """ +from cadx.ceus._anchor_frames import ( + ANCHOR_SLOTS, + STATUS_ANCHOR_UNDEFINED, + STATUS_NO_FRAME_IN_WINDOW, + STATUS_NO_ORDERED_FRAME, + STATUS_NOT_COVERED, + STATUS_OK, + AnchorFrameLocation, + locate_anchor_frames, +) from cadx.ceus._config import ( AnchorArrivalConfig, AnchorFramesConfig, @@ -25,6 +38,15 @@ from cadx.ceus._config import ( ViewsConfig, ) from cadx.ceus._contrast_side import ContrastSide, ContrastSideError, locate_contrast_side +from cadx.ceus._dark_region import ( + apply_dark_morphology, + dark_candidate_mask, + extract_dark_region_masks, + extract_dark_region_polygons, + fill_binary_holes, + score_dark_candidate, + split_dark_components, +) from cadx.ceus._errors import CeusCaseError from cadx.ceus._geometry import ( ViewRect, @@ -49,7 +71,33 @@ from cadx.ceus._green_prompt import ( prompt_color_mask, ) from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source, view_offset +from cadx.ceus._overlay import OverlayConfig, OverlayLayers, draw_polygon_overlay, render_overlay +from cadx.ceus._perfusion_metrics import ( + LognormalPerfusionMetrics, + compute_lognormal_perfusion_metrics, +) +from cadx.ceus._pipeline import ( + LESION_CURVES, + QuantResult, + SegmentationInput, + keyframe_images, + prepare_segmentation, + quant_summary, + quantify_case, + render_case_overlay, + tic_plot_markers, +) +from cadx.ceus._polygons import ( + NoValidContourError, + align_polygon_to_previous, + apply_prompt_frame_position_constraints, + fuse_prompt_prior_with_medsam, + largest_contour, + mask_center, + masks_to_smoothed_polygons, +) from cadx.ceus._prompt_frames import PromptFrame, select_prompt_frames +from cadx.ceus._reference_region import select_reference_region from cadx.ceus._split_screen import ( SplitDetectionError, detect_split_x, @@ -57,12 +105,54 @@ from cadx.ceus._split_screen import ( split_views, view_rect, ) +from cadx.ceus._tic import ( + CURVE_NAMES, + BaselineResult, + TicResult, + compute_tic_for_video, + detect_baseline, + mean_or_nan, +) +from cadx.ceus._tic_curves import ( + CURVES_SCHEMA_VERSION, + TicCurves, + TicCurveSeries, + from_tic_result, + pack_tic_curves, + unpack_tic_curves, +) +from cadx.ceus._tic_fitting import ( + LognormalFitResult, + LognormalParameters, + evaluate_lognormal, + evaluate_lognormal_derivative, + fit_lognormal_curve, +) +from cadx.ceus._tic_plot import TicPlotMarker, render_tic_png +from cadx.ceus._trajectory import ( + POLYGON_NUM_POINTS, + Trajectory, + pack_trajectory, + unpack_trajectory, +) from cadx.ceus._video import VideoFile __all__ = [ + "ANCHOR_SLOTS", + "CURVES_SCHEMA_VERSION", + "CURVE_NAMES", + "LESION_CURVES", + "POLYGON_NUM_POINTS", + "STATUS_ANCHOR_UNDEFINED", + "STATUS_NOT_COVERED", + "STATUS_NO_FRAME_IN_WINDOW", + "STATUS_NO_ORDERED_FRAME", + "STATUS_OK", "AnchorArrivalConfig", + "AnchorFrameLocation", "AnchorFramesConfig", "AnchorLatePhaseConfig", + "BaselineResult", "CeusCaseError", "CeusConfig", "ColorPrompt", @@ -72,42 +162,91 @@ __all__ = [ "InteriorDarkRegionConfig", "KeyframePositionConstraint", "KeyframesConfig", + "LognormalFitResult", + "LognormalParameters", + "LognormalPerfusionMetrics", + "NoValidContourError", + "OverlayConfig", + "OverlayLayers", "PanSourceConfig", "PromptFrame", "PromptObservation", "PromptSourceConfig", + "QuantResult", "ReferenceRegionConfig", "RoiShapeConfig", + "SegmentationInput", "SmoothingConfig", "SplitDetectionError", "TicAnalysisConfig", "TicBaselineConfig", + "TicCurveSeries", + "TicCurves", "TicFittingConfig", + "TicPlotMarker", + "TicResult", + "Trajectory", "VideoFile", "ViewRect", "ViewsConfig", + "align_polygon_to_previous", + "apply_dark_morphology", + "apply_prompt_frame_position_constraints", "collect_prompt_observations", + "compute_lognormal_perfusion_metrics", + "compute_tic_for_video", "crop_frame", + "dark_candidate_mask", + "detect_baseline", "detect_split_x", + "draw_polygon_overlay", + "evaluate_lognormal", + "evaluate_lognormal_derivative", "extract_color_prompt", + "extract_dark_region_masks", + "extract_dark_region_polygons", + "fill_binary_holes", + "fit_lognormal_curve", + "from_tic_result", + "fuse_prompt_prior_with_medsam", + "keyframe_images", + "largest_contour", "local_rect_within", + "locate_anchor_frames", "locate_contrast_side", "map_source_mask_to_target", + "mask_center", "mask_iou", + "masks_to_smoothed_polygons", + "mean_or_nan", "odd_window", "optional_positive_int", + "pack_tic_curves", + "pack_trajectory", "polygon_area", "polygon_center", "polygon_to_mask", "polygons_to_mask", + "prepare_segmentation", "prompt_color_mask", + "quant_summary", + "quantify_case", "rect_from_values", + "render_case_overlay", + "render_overlay", + "render_tic_png", "resample_closed_contour", "sample_frames_for_split", + "score_dark_candidate", "select_prompt_frames", + "select_reference_region", + "split_dark_components", "split_views", "target_polygons_to_source", + "tic_plot_markers", "triangular_smooth", + "unpack_tic_curves", + "unpack_trajectory", "view_offset", "view_rect", ] diff --git a/src/cadx/ceus/_anchor_frames.py b/src/cadx/ceus/_anchor_frames.py new file mode 100644 index 0000000..7aa148c --- /dev/null +++ b/src/cadx/ceus/_anchor_frames.py @@ -0,0 +1,419 @@ +"""第 8 步:把关键帧时刻映射到真实、有效且不重复的视频帧,共 7 个槽位。 + +- B0、D1、D2、D3、D4:从 `roi_perf`(病灶去暗区)曲线的 Lognormal 拟合上取。B0 是到达时刻再往前 + 退 `baseline_frame_backoff_s` 秒,只在观测到的基线段里、到达之前取帧;D1 是拟合曲线上的到达 + 时刻(design 0002 决定 6:不用医生标注的造影剂到达时间);D2、D3、D4 是洗入拐点、峰值、 + 洗出拐点。病灶两条曲线(`roi_all`、`roi_perf`)的拟合没有都过质量门时,这 5 个槽位全部未定位。 + D1–D4 在各自的容差窗口里找帧,并保持时间先后(不会出现 D2 的帧早于 D1 的帧)。 +- F1、F2:推注后第 `f1_anchor_s`、`late_phase.preferred_time_s` 秒。`injection_offset_s`(下称 X) + 是超声报告表的「推注开始时间s」,含义是视频第 0 秒时已经推注了 X 秒(design 0002 决定 5), + 所以推注后第 T 秒在视频第 T − X 秒。落在视频有效帧范围之外(早于第一帧或晚于最后一帧), + 这一槽位记为未定位,不拿别的帧补。 + +每个槽位交出帧号、视频时间、状态和未定位的原因;个别槽位未定位不算这一例失败。 + +照搬旧项目 `anchor_frames.py`,两处改动(任务书 02「四处要改的地方」第 3、4 条):删掉 D1 取医生 +标注时刻的 `excel_video` 模式;推注时间的符号按上面的含义(旧 `excel_video` 模式按「推注发生在 +视频第 X 秒」算成了 T + X)。 +""" + +import math +from dataclasses import dataclass + +import numpy as np + +from cadx.ceus._config import AnchorFramesConfig +from cadx.ceus._geometry import AnyArray +from cadx.ceus._tic_fitting import LognormalFitResult + +ANCHOR_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2") + +STATUS_OK = "ok" +STATUS_ANCHOR_UNDEFINED = "anchor_undefined" +STATUS_NO_FRAME_IN_WINDOW = "no_frame_in_window" +STATUS_NO_ORDERED_FRAME = "no_ordered_frame" +STATUS_NOT_COVERED = "not_covered" + + +@dataclass(frozen=True) +class AnchorFrameLocation: + """一个关键帧槽位的定位结果。 + + - `frame_index`、`frame_time_video_s`:定位到的帧号和它的视频时间,未定位为 None; + - `time_s`:这一帧在推注时间轴上的时刻(视频时间 + X),没有推注时间或未定位为 None; + - `status`:`ok` 或未定位的类别(`anchor_undefined`、`not_covered`、`no_frame_in_window`、 + `no_ordered_frame`);`reason`:未定位的原因(中文),定位到时为 None; + - `anchor_time_video_s`:要找的那个时刻(视频时间),没有时为 None; + - `time_error_s`:帧时间减锚点时刻;`source_curve`:时刻来自哪条曲线或推注时钟。 + """ + + slot: str + frame_index: int | None + time_s: float | None + status: str + reason: str | None + anchor_time_video_s: float | None + frame_time_video_s: float | None + time_error_s: float | None + source_curve: str | None + + +def _finite_offset(injection_offset_s: float | None) -> float | None: + """推注时间 X:None 表示没有;有就必须是有限非负秒数,否则报错。""" + if injection_offset_s is None: + return None + parsed = float(injection_offset_s) + if not math.isfinite(parsed) or parsed < 0.0: + raise ValueError(f"injection_offset_s 必须是有限非负秒数,收到 {injection_offset_s}") + return parsed + + +def _validate_axis(frame_indices: AnyArray, time_video_s: AnyArray) -> tuple[AnyArray, AnyArray]: + """帧号与视频时间必须等长、非空、严格递增。""" + indices = np.asarray(frame_indices, dtype=np.int32) + times = np.asarray(time_video_s, dtype=np.float64) + if indices.ndim != 1 or times.shape != indices.shape or indices.size == 0: + raise ValueError("frame_indices 与 time_video_s 必须是等长非空一维数组") + if np.any(np.diff(indices) <= 0): + raise ValueError("frame_indices 必须严格递增") + if not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0): + raise ValueError("time_video_s 必须有限且严格递增") + return indices, times + + +def _nearest_frame( + anchor_time_s: float, + window_before_s: float, + window_after_s: float, + frame_indices: AnyArray, + time_video_s: AnyArray, + eligible_mask: AnyArray, +) -> int | None: + """窗口 `[锚点 − before, 锚点 + after]` 内离锚点最近的可用帧;距离相同取更早的。""" + eligible = ( + np.asarray(eligible_mask, dtype=bool) + & (time_video_s >= anchor_time_s - window_before_s) + & (time_video_s <= anchor_time_s + window_after_s) + ) + positions = np.flatnonzero(eligible) + if positions.size == 0: + return None + distances = np.abs(time_video_s[positions] - anchor_time_s) + order = np.lexsort((time_video_s[positions], distances)) + return int(frame_indices[positions[int(order[0])]]) + + +type _State = tuple[int, float, tuple[int | None, ...]] + + +def _ordered_anchor_frames( + anchor_specs: tuple[tuple[str, float, float, float], ...], + frame_indices: AnyArray, + time_video_s: AnyArray, + eligible_mask: AnyArray, +) -> dict[str, int]: + """给 D1–D4 选帧:先保留最多的槽位,再让严格递增映射的总时间误差最小(动态规划)。""" + eligible = np.asarray(eligible_mask, dtype=bool) + states: dict[int, _State] = {-1: (0, 0.0, ())} + for _slot, anchor_time_s, before_s, after_s in anchor_specs: + positions = np.flatnonzero( + eligible + & (time_video_s >= anchor_time_s - before_s) + & (time_video_s <= anchor_time_s + after_s) + ) + next_states: dict[int, _State] = {} + for previous, (count, cost, path) in states.items(): + skipped: _State = (count, cost, (*path, None)) + incumbent = next_states.get(previous) + if incumbent is None or _state_key(skipped) < _state_key(incumbent): + next_states[previous] = skipped + for position_value in positions.tolist(): + position = int(position_value) + if position <= previous: + continue + assigned: _State = ( + count + 1, + cost + abs(float(time_video_s[position]) - anchor_time_s), + (*path, position), + ) + incumbent = next_states.get(position) + if incumbent is None or _state_key(assigned) < _state_key(incumbent): + next_states[position] = assigned + states = next_states + + _count, _cost, best_path = min(states.values(), key=_state_key) + return { + spec[0]: int(frame_indices[position]) + for spec, position in zip(anchor_specs, best_path, strict=True) + if position is not None + } + + +def _state_key(state: _State) -> tuple[int, float, tuple[int, ...]]: + """动态规划的比较键:槽位多者优先,其次总误差小者,再按路径定序。""" + count, cost, path = state + sortable_path = tuple(-1 if position is None else position for position in path) + return -count, cost, sortable_path + + +def _video_time_for_frame( + frame_index: int | None, + frame_indices: AnyArray, + time_video_s: AnyArray, +) -> float | None: + """帧号对应的视频时间;帧号必须在时间轴里恰好出现一次。""" + if frame_index is None: + return None + positions = np.flatnonzero(frame_indices == frame_index) + if positions.size != 1: + raise ValueError(f"帧号 {frame_index} 在时间轴中不是唯一值") + return float(time_video_s[int(positions[0])]) + + +def _fit_eligible(fit: LognormalFitResult) -> AnyArray: + """拟合用到的有效且不重复的样本。""" + return fit.valid_sample_mask & ~fit.duplicate_sample_mask + + +def _span_text(start_s: float, end_s: float) -> str: + return f"[{start_s:.2f}, {end_s:.2f}] 秒" + + +def locate_anchor_frames( + *, + perf_fit: LognormalFitResult, + perf_baseline_end_index: int, + lesion_fit_ready: bool, + frame_indices: AnyArray, + time_video_s: AnyArray, + injection_offset_s: float | None, + cfg: AnchorFramesConfig, +) -> dict[str, AnchorFrameLocation]: + """定位 7 个槽位,按 `ANCHOR_SLOTS` 的顺序交出。 + + `lesion_fit_ready` 是病灶两条曲线的拟合是否都过了质量门;`injection_offset_s` 是推注时间 X + (秒,视频第 0 秒时已推注的时长),None 时 F1、F2 未定位。 + """ + indices, times = _validate_axis(frame_indices, time_video_s) + if perf_fit.valid_sample_mask.shape != times.shape: + raise ValueError("perf_fit 的样本掩膜长度与时间轴不一致") + injection_axis_offset_s = _finite_offset(injection_offset_s) + arrival_time = perf_fit.d1_time_s + locations: dict[str, AnchorFrameLocation] = {} + + def record( + slot: str, + frame_index: int | None, + status: str, + reason: str | None, + *, + anchor_time_video_s: float | None, + source_curve: str | None, + ) -> None: + frame_time_video_s = _video_time_for_frame(frame_index, indices, times) + locations[slot] = AnchorFrameLocation( + slot=slot, + frame_index=frame_index, + time_s=( + None + if frame_time_video_s is None or injection_axis_offset_s is None + else frame_time_video_s + injection_axis_offset_s + ), + status=status, + reason=None if status == STATUS_OK else reason, + anchor_time_video_s=anchor_time_video_s, + frame_time_video_s=frame_time_video_s, + time_error_s=( + None + if frame_time_video_s is None or anchor_time_video_s is None + else frame_time_video_s - anchor_time_video_s + ), + source_curve=source_curve, + ) + + model_ready = lesion_fit_ready and perf_fit.success + if not lesion_fit_ready: + fit_failure = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门" + elif not perf_fit.success: + fit_failure = f"roi_perf 曲线的 Lognormal 拟合没过质量门({perf_fit.failure_reason})" + else: + fit_failure = "" + + perf_eligible = _fit_eligible(perf_fit) + + b0_ready = arrival_time is not None and perf_fit.baseline_source == "observed" and model_ready + if b0_ready and arrival_time is not None: + b0_time = float(arrival_time) - cfg.baseline_frame_backoff_s + frame = _nearest_frame( + b0_time, + cfg.b0_window_s, + cfg.b0_window_s, + indices, + times, + perf_eligible + & (np.arange(times.size) < perf_baseline_end_index) + & (times < float(arrival_time)), + ) + record( + "B0", + frame, + STATUS_OK if frame is not None else STATUS_NO_FRAME_IN_WINDOW, + f"B0 锚点 {b0_time:.2f} 秒的容差窗口 " + f"{_span_text(b0_time - cfg.b0_window_s, b0_time + cfg.b0_window_s)} 内," + "观测到的基线段里、到达时刻之前没有可用帧", + anchor_time_video_s=b0_time, + source_curve="roi_perf", + ) + else: + if not model_ready: + b0_reason = fit_failure + elif arrival_time is None: + b0_reason = "拟合曲线升不过到达门槛,没有到达时刻" + else: + b0_reason = "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义" + record( + "B0", + None, + STATUS_ANCHOR_UNDEFINED, + b0_reason, + anchor_time_video_s=None, + source_curve="roi_perf", + ) + + raw_specs = ( + ("D1", arrival_time, 0.0, cfg.d1_window_after_s), + ("D2", perf_fit.d2_time_s, cfg.d2_window_s, cfg.d2_window_s), + ("D3", perf_fit.d3_time_s, cfg.d3_window_s, cfg.d3_window_s), + ("D4", perf_fit.d4_time_s, cfg.d4_window_s, cfg.d4_window_s), + ) + eligible_times = times[perf_eligible] + video_start_s = float(eligible_times[0]) if eligible_times.size else math.inf + video_end_s = float(eligible_times[-1]) if eligible_times.size else -math.inf + covered_specs = tuple( + (slot, float(anchor_time), before, after) + for slot, anchor_time, before, after in raw_specs + if model_ready + and anchor_time is not None + and video_start_s <= float(anchor_time) <= video_end_s + ) + dynamic_eligible = perf_eligible + if model_ready: + dynamic_eligible &= times < perf_fit.fit_end_s + ordered = _ordered_anchor_frames(covered_specs, indices, times, dynamic_eligible) + for slot, anchor_time, before, after in raw_specs: + if not model_ready: + record( + slot, + None, + STATUS_ANCHOR_UNDEFINED, + fit_failure, + anchor_time_video_s=anchor_time, + source_curve="roi_perf", + ) + elif anchor_time is None: + record( + slot, + None, + STATUS_NOT_COVERED if slot == "D4" else STATUS_ANCHOR_UNDEFINED, + "峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4" + if slot == "D4" + else f"拟合曲线上没有 {slot} 时刻(升不过到达门槛)", + anchor_time_video_s=None, + source_curve="roi_perf", + ) + elif not video_start_s <= float(anchor_time) <= video_end_s: + record( + slot, + None, + STATUS_NOT_COVERED, + f"{slot} 时刻 {float(anchor_time):.2f} 秒落在视频有效帧范围 " + f"{_span_text(video_start_s, video_end_s)} 之外", + anchor_time_video_s=float(anchor_time), + source_curve="roi_perf", + ) + elif slot in ordered: + record( + slot, + ordered[slot], + STATUS_OK, + None, + anchor_time_video_s=float(anchor_time), + source_curve="roi_perf", + ) + else: + window = _span_text(float(anchor_time) - before, float(anchor_time) + after) + has_candidate = bool( + np.any( + dynamic_eligible + & (times >= float(anchor_time) - before) + & (times <= float(anchor_time) + after) + ) + ) + record( + slot, + None, + STATUS_NO_ORDERED_FRAME if has_candidate else STATUS_NO_FRAME_IN_WINDOW, + f"{slot} 容差窗口 {window} 内的候选帧都被时间更早的槽位占用,无法保持先后顺序" + if has_candidate + else f"{slot} 容差窗口 {window} 内没有可用帧(拟合区间内、不重复)", + anchor_time_video_s=float(anchor_time), + source_curve="roi_perf", + ) + + globally_eligible = ~perf_fit.duplicate_sample_mask + for slot, injection_time_s, tolerance_s in ( + ("F1", cfg.f1_anchor_s, None), + ("F2", cfg.late_phase.preferred_time_s, cfg.late_phase.tolerance_s), + ): + if injection_axis_offset_s is None: + record( + slot, + None, + STATUS_NOT_COVERED, + "没有推注时间,推注后时刻换算不到视频时间", + anchor_time_video_s=None, + source_curve="injection_clock", + ) + continue + video_time_s = injection_time_s - injection_axis_offset_s + fixed_eligible_times = times[globally_eligible] + if ( + fixed_eligible_times.size == 0 + or video_time_s < float(fixed_eligible_times[0]) + or video_time_s > float(fixed_eligible_times[-1]) + ): + span = ( + "没有不重复的帧" + if fixed_eligible_times.size == 0 + else f"视频有效帧范围 " + f"{_span_text(float(fixed_eligible_times[0]), float(fixed_eligible_times[-1]))}" + ) + record( + slot, + None, + STATUS_NOT_COVERED, + f"推注后第 {injection_time_s:g} 秒在视频第 {video_time_s:.2f} 秒" + f"(推注时间 X={injection_axis_offset_s:g} 秒),落在{span}之外", + anchor_time_video_s=video_time_s, + source_curve="injection_clock", + ) + continue + window = float("inf") if tolerance_s is None else tolerance_s + frame = _nearest_frame( + video_time_s, + window, + window, + indices, + times, + globally_eligible, + ) + record( + slot, + frame, + STATUS_OK if frame is not None else STATUS_NO_FRAME_IN_WINDOW, + f"视频第 {video_time_s:.2f} 秒 ±{window:g} 秒内没有不重复的帧", + anchor_time_video_s=video_time_s, + source_curve="injection_clock", + ) + + return {slot: locations[slot] for slot in ANCHOR_SLOTS} diff --git a/src/cadx/ceus/_config.py b/src/cadx/ceus/_config.py index 5713fb0..5364a6c 100644 --- a/src/cadx/ceus/_config.py +++ b/src/cadx/ceus/_config.py @@ -211,6 +211,10 @@ class InteriorDarkRegionConfig(BaseModel): threshold_offset: int min_area: int min_area_ratio: float + max_regions: int | None + max_contours_per_region: int | None + preferred_area_ratio_range: list[float] | None + prompt_exclude_dilate: int preserve_holes: bool fill_holes: bool morphology_kernel: int @@ -235,6 +239,25 @@ class InteriorDarkRegionConfig(BaseModel): raise ValueError(f"interior_dark_region.adaptive_block_size 必须 ≥3,收到 {validated}") return validated + @field_validator("max_regions", "max_contours_per_region") + @classmethod + def _optional_count_positive(cls, value: int | None) -> int | None: + """截断个数为 null(不限)或正整数;0 会把暗区全部截掉而不报错。""" + if value is not None and value < 1: + raise ValueError(f"interior_dark_region 的截断个数必须为 null 或 ≥1,收到 {value}") + return value + + @field_validator("preferred_area_ratio_range") + @classmethod + def _area_range_pair(cls, value: list[float] | None) -> list[float] | None: + """偏好面积比区间为 null 或 [lo, hi],0 ≤ lo ≤ hi ≤ 1。""" + if value is not None and not (len(value) == 2 and 0.0 <= value[0] <= value[1] <= 1.0): + raise ValueError( + "interior_dark_region.preferred_area_ratio_range 必须为 null 或 " + f"0≤lo≤hi≤1 的 [lo, hi],收到 {value}" + ) + return value + @field_validator("percentile") @classmethod def _percentile_within_bounds(cls, value: float) -> float: diff --git a/src/cadx/ceus/_dark_region.py b/src/cadx/ceus/_dark_region.py new file mode 100644 index 0000000..0f28e07 --- /dev/null +++ b/src/cadx/ceus/_dark_region.py @@ -0,0 +1,670 @@ +"""第 5 步:在病灶 ROI 内找暗区(不进造影剂、一直发黑的囊液部分)。 + +哪种阈值法在当前帧最可信事先定不了,所以 `threshold_method="auto_ensemble"` 时对 +`candidate_methods` 里的每种阈值法各出一份暗块候选,用统一评分(明暗对比 + 面积分 +− 碎片惩罚 + 与上一帧的时间一致性)择优。 + +- `dark_candidate_mask`:按方法在 ROI 灰度内出候选暗掩膜(otsu / percentile / absolute / + adaptive / kmeans / clahe_otsu);`threshold_offset` 只对前三种生效;末尾过形态学; +- `apply_dark_morphology`:闭、开运算各一次(`morphology_kernel` 方核,≤1 跳过); + `fill_holes` 且不 `preserve_holes` 时填洞;最后与采样掩膜取交; +- `split_dark_components`:连通域拆分,面积门槛 `max(min_area, ROI 面积 × min_area_ratio)`; + `preserve_holes` 时把洞挖回;按面积降序,`max_regions` 截断; +- `score_dark_candidate`:评分(三个常量 64.0 / 4.0 / 2.0); +- `extract_dark_region_masks`:单帧——剔绿框色 → 各候选法择优 → 拆分; +- `extract_dark_region_polygons`:逐帧编排,`enabled=False` 时每帧空列表(仍保留帧对齐); + 否则各暗块取显著轮廓、等弧长重采样到 `contour_points` 点。 + +输入是造影那一半画面(`analysis_view: source`)的逐帧 BGR 与 ROI 多边形,输出同一坐标系的暗区 +多边形。照搬旧项目 `dark_region.py`,去掉产品层;旧代码用 `getattr(..., 默认值)` 读的四个旧 schema +里没有的键(`max_regions`、`max_contours_per_region`、`preferred_area_ratio_range`、 +`prompt_exclude_dilate`)改成配置字段、值照旧;剔绿用的 HSV 阈值只取 `prompt_source`, +旧代码在没传时回退的写死阈值删掉。 +""" + +from collections.abc import Iterable, Sequence +from typing import Any, Protocol, cast + +import cv2 +import numpy as np + +from cadx.ceus._geometry import ( + AnyArray, + Mask, + mask_iou, + polygon_to_mask, + resample_closed_contour, +) + +# 评分公式的三个常量(照参考组件 P:1296 / 1308 / 1310)。 +_CONTRAST_NORMALIZER = 64.0 # 明暗对比归一化除数 +_AREA_SLOPE = 4.0 # preferred_area_ratio_range 之外面积分的线性衰减斜率 +_AREA_SCORE_GAIN = 2.0 # 没有 preferred_area_ratio_range 时面积分的系数 + +_EMPTY_SCORE = -1e9 # 空候选的哨兵分:择优时永远不会被选中 + + +class _DarkRegionConfigLike(Protocol): + """暗区配置(`InteriorDarkRegionConfig` 符合)。""" + + @property + def enabled(self) -> bool: ... + @property + def exclude_prompt_color(self) -> bool: ... + @property + def prompt_exclude_dilate(self) -> int: ... + @property + def threshold_method(self) -> str: ... + @property + def candidate_methods(self) -> Sequence[str]: ... + @property + def threshold_offset(self) -> int: ... + @property + def min_area(self) -> int: ... + @property + def min_area_ratio(self) -> float: ... + @property + def max_regions(self) -> int | None: ... + @property + def max_contours_per_region(self) -> int | None: ... + @property + def preferred_area_ratio_range(self) -> list[float] | None: ... + @property + def preserve_holes(self) -> bool: ... + @property + def fill_holes(self) -> bool: ... + @property + def morphology_kernel(self) -> int: ... + @property + def fragment_penalty_weight(self) -> float: ... + @property + def temporal_consistency_weight(self) -> float: ... + @property + def contour_points(self) -> int: ... + @property + def percentile(self) -> float: ... + @property + def threshold(self) -> int: ... + @property + def adaptive_block_size(self) -> int: ... + @property + def adaptive_c(self) -> float: ... + @property + def kmeans_max_iter(self) -> int: ... + @property + def kmeans_attempts(self) -> int: ... + @property + def clahe_clip_limit(self) -> float: ... + @property + def clahe_tile_grid_size(self) -> int: ... + + +class _PromptHsvConfigLike(Protocol): + """剔绿框色用的 HSV 阈值(`PromptSourceConfig` 符合)。""" + + @property + def hsv_lower(self) -> list[int]: ... + @property + def hsv_upper(self) -> list[int]: ... + + +def fill_binary_holes(mask: AnyArray) -> AnyArray: + """对二值 mask 填充内部空洞(参考 P:1112-1119)。 + + 参数: + mask: ``(h, w)`` 的二值数组(bool 或非零即真)。 + + 返回: + 填洞后的 ``dtype=bool`` mask(外接背景之外的所有像素均置 True)。 + + 关键实现: + 从角点 ``(0, 0)`` 泛洪标记"可从外部到达的背景",取反即得"被前景包围的洞", + 与原前景按位或合并。要求 ``(0, 0)`` 像素为背景(暗区不会触达画面角点,成立)。 + """ + filled = np.asarray(mask, dtype=np.uint8) * np.uint8(255) + flood = filled.copy() + height, width = flood.shape + flood_mask = np.zeros((height + 2, width + 2), dtype=np.uint8) + cv2.floodFill(flood, flood_mask, (0, 0), 255) + holes = np.asarray(cv2.bitwise_not(flood), dtype=np.uint8) + return (filled | holes) > 0 + + +def apply_dark_morphology( + dark: AnyArray, + sample_mask: AnyArray, + dark_cfg: _DarkRegionConfigLike, +) -> AnyArray: + """对候选暗 mask 做形态学闭 / 开运算并约束回 sample_mask(参考 P:1122-1136)。 + + 参数: + dark: 候选暗 mask(bool)。 + sample_mask: 分析采样掩膜(ROI 内、已剔绿的有效区);结果与其取交。 + dark_cfg: 暗区配置(取 ``morphology_kernel`` / ``fill_holes`` / ``preserve_holes``)。 + + 返回: + 形态学处理后、且**仅落在 sample_mask 内**的 ``dtype=bool`` mask。 + + 关键实现: + 方核大小 ``> 1`` 时先 CLOSE(补小缺口)再 OPEN(去毛刺)各一次; + 仅当 ``fill_holes=True 且 preserve_holes=False`` 才填洞(保洞优先于填洞); + 末尾与 sample_mask 取交,杜绝形态学膨胀外溢 ROI。 + """ + kernel_size = int(dark_cfg.morphology_kernel) + if kernel_size > 1: + kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8) + dark_u8 = dark.astype(np.uint8) * 255 + dark_u8 = cv2.morphologyEx(dark_u8, cv2.MORPH_CLOSE, kernel, iterations=1) + dark_u8 = cv2.morphologyEx(dark_u8, cv2.MORPH_OPEN, kernel, iterations=1) + dark = dark_u8 > 0 + if bool(dark_cfg.fill_holes) and not bool(dark_cfg.preserve_holes): + dark = fill_binary_holes(dark) + return dark & sample_mask + + +def _otsu_threshold(values: AnyArray) -> float: + """对一维灰度样本求 Otsu 阈值(参考 P:1150-1155 / 1206-1211)。""" + threshold, _ = cv2.threshold(values.reshape(-1, 1), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + return float(threshold) + + +def _candidate_otsu(gray: AnyArray, roi_values: AnyArray) -> AnyArray: + """Otsu 法候选:ROI 灰度 Otsu 阈值,``gray <= 阈值`` 为暗(P:1149-1156)。""" + return gray <= _otsu_threshold(roi_values) + + +def _candidate_percentile( + gray: AnyArray, roi_values: AnyArray, dark_cfg: _DarkRegionConfigLike +) -> AnyArray: + """分位法候选:ROI 灰度的 ``percentile`` 分位作阈值(P:1157-1159)。""" + threshold = float(np.percentile(roi_values, float(dark_cfg.percentile))) + return gray <= threshold + + +def _candidate_absolute(gray: AnyArray, dark_cfg: _DarkRegionConfigLike) -> AnyArray: + """绝对阈法候选:固定灰度阈 ``threshold``(P:1160-1162)。""" + return gray <= float(dark_cfg.threshold) + + +def _candidate_adaptive(gray: AnyArray, dark_cfg: _DarkRegionConfigLike) -> AnyArray: + """自适应阈法候选:高斯邻域自适应 + ``THRESH_BINARY_INV``(P:1163-1176)。 + + block_size 强制奇数且 ``>= 3``(偶数加 1);inverse 二值化使暗部为前景。 + """ + block_size = int(dark_cfg.adaptive_block_size) + if block_size % 2 == 0: + block_size += 1 + block_size = max(3, block_size) + adaptive = cv2.adaptiveThreshold( + gray, + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, + block_size, + float(dark_cfg.adaptive_c), + ) + return adaptive > 0 + + +def _candidate_kmeans( + sample_mask: AnyArray, roi_values: AnyArray, dark_cfg: _DarkRegionConfigLike +) -> AnyArray: + """K-means 二聚类候选:ROI 灰度聚成两簇,低均值簇为暗(P:1177-1196)。 + + EPS=0.5 硬编码(与参考一致);样本不足 2 个时返回全 False。结果仅写回 sample_mask 内。 + """ + values = roi_values.reshape(-1, 1).astype(np.float32) + if len(values) < 2: + return np.zeros_like(sample_mask, dtype=bool) + criteria = ( + cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, + int(dark_cfg.kmeans_max_iter), + 0.5, + ) + # cv2 的类型存根不接受 bestLabels=None(运行时接受,表示不给初始标签)。 + kmeans = cast(Any, cv2.kmeans) + _, labels, centers = cast( + tuple[float, AnyArray, AnyArray], + kmeans( + values, + 2, + None, + criteria, + int(dark_cfg.kmeans_attempts), + cv2.KMEANS_PP_CENTERS, + ), + ) + dark_label = int(np.argmin(centers.reshape(-1))) + dark = np.zeros_like(sample_mask, dtype=bool) + dark[sample_mask] = labels.reshape(-1) == dark_label + return dark + + +def _candidate_clahe_otsu( + gray: AnyArray, sample_mask: AnyArray, dark_cfg: _DarkRegionConfigLike +) -> AnyArray: + """CLAHE 增强后 Otsu 候选:先局部直方图均衡再在 ROI 内 Otsu(P:1197-1212)。""" + clahe = cv2.createCLAHE( + clipLimit=float(dark_cfg.clahe_clip_limit), + tileGridSize=( + int(dark_cfg.clahe_tile_grid_size), + int(dark_cfg.clahe_tile_grid_size), + ), + ) + enhanced = clahe.apply(gray) + threshold = _otsu_threshold(enhanced[sample_mask]) + return enhanced <= threshold + + +def _apply_threshold_offset( + dark: AnyArray, + gray: AnyArray, + sample_mask: AnyArray, + method: str, + dark_cfg: _DarkRegionConfigLike, +) -> AnyArray: + """对 otsu / percentile / absolute 三法施加 ``threshold_offset``(参考 P:1216-1233)。 + + 其余方法(adaptive / kmeans / clahe_otsu)不支持偏移,原样返回。偏移为 0 时也原样返回。 + 重新以"基准阈值 + 偏移"二值化(不是在已二值化结果上平移),与参考严格等价。 + """ + threshold_offset = float(dark_cfg.threshold_offset) + if threshold_offset == 0 or method not in {"otsu", "percentile", "absolute"}: + return dark + base_values = gray[sample_mask] + if method == "otsu": + return gray <= (_otsu_threshold(base_values) + threshold_offset) + if method == "percentile": + base = float(np.percentile(base_values, float(dark_cfg.percentile))) + return gray <= (base + threshold_offset) + # absolute + return gray <= (float(dark_cfg.threshold) + threshold_offset) + + +def dark_candidate_mask( + gray: AnyArray, + sample_mask: AnyArray, + method: str, + dark_cfg: _DarkRegionConfigLike, +) -> AnyArray: + """按指定阈值法在 ROI 灰度内出候选暗 mask(参考 P:1139-1235)。 + + 参数: + gray: ``(h, w)`` 单通道 uint8 灰度图(整帧)。 + sample_mask: 分析采样掩膜(ROI 内、已剔绿);阈值统计只在其内取灰度。 + method: 阈值法名(otsu / percentile / absolute / adaptive / kmeans / clahe_otsu)。 + dark_cfg: 暗区配置(提供各法子参与 ``threshold_offset``)。 + + 返回: + 过形态学并约束回 sample_mask 后的 ``dtype=bool`` 暗 mask; + sample_mask 为空时返回全 False。 + + 关键实现: + 各法分派到独立 helper(控复杂度);除 adaptive/clahe 在全图取阈外,统计量 + 均取自 ``gray[sample_mask]``。``threshold_offset`` 仅对 otsu/percentile/absolute + 生效(见 ``_apply_threshold_offset``)。末尾 ``(dark & sample_mask)`` 再过 + ``apply_dark_morphology``(其内会再次与 sample_mask 取交)。未知方法直接报错(不兜底)。 + """ + roi_values = gray[sample_mask] + if len(roi_values) == 0: + return np.zeros_like(sample_mask, dtype=bool) + + if method == "otsu": + dark = _candidate_otsu(gray, roi_values) + elif method == "percentile": + dark = _candidate_percentile(gray, roi_values, dark_cfg) + elif method == "absolute": + dark = _candidate_absolute(gray, dark_cfg) + elif method == "adaptive": + dark = _candidate_adaptive(gray, dark_cfg) + elif method == "kmeans": + dark = _candidate_kmeans(sample_mask, roi_values, dark_cfg) + elif method == "clahe_otsu": + dark = _candidate_clahe_otsu(gray, sample_mask, dark_cfg) + else: + raise RuntimeError(f"不支持的 interior_dark_region.threshold_method: {method}") + + dark = _apply_threshold_offset(dark, gray, sample_mask, method, dark_cfg) + return apply_dark_morphology(dark & sample_mask, sample_mask, dark_cfg) + + +def split_dark_components( + dark: AnyArray, + roi_mask: AnyArray, + dark_cfg: _DarkRegionConfigLike, +) -> list[AnyArray]: + """把候选暗 mask 拆成达标连通域列表(参考 P:1238-1274)。 + + 参数: + dark: 候选暗 mask(bool)。 + roi_mask: ROI 掩膜(**未剔绿的原始 ROI**,用于面积阈与组件裁剪)。 + dark_cfg: 暗区配置(``min_area`` / ``min_area_ratio`` / ``preserve_holes`` / + ``max_regions``)。 + + 返回: + 按面积降序排列的连通域 ``dtype=bool`` mask 列表; + 每个面积须 ``>= max(min_area, ROI面积×min_area_ratio)``;``max_regions`` 截断。 + + 关键实现: + 组件阈 ``max(min_area, roi_mask.sum()×min_area_ratio)``(``min_area_ratio>0`` 才生效)。 + ``findContours(RETR_CCOMP)`` 取外 / 内两层;仅遍历外轮廓(``hierarchy[3]<0``), + 实心填充后——若 ``preserve_holes``——再把其子轮廓(洞)挖回 0,最后与 roi_mask 取交。 + ``max_regions`` 为 None 时不限。 + """ + min_area = float(dark_cfg.min_area) + min_area_ratio = float(dark_cfg.min_area_ratio) + if min_area_ratio > 0: + min_area = max(min_area, float(roi_mask.sum()) * min_area_ratio) + + contours, hierarchy = cv2.findContours( + dark.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE + ) + # 没有轮廓时 cv2 交回的 hierarchy 是 None。 + if not contours: + return [] + hierarchy = np.asarray(hierarchy)[0] + preserve_holes = bool(dark_cfg.preserve_holes) + components: list[tuple[int, AnyArray]] = [] + for idx, _contour in enumerate(contours): + if hierarchy[idx][3] >= 0: + continue + out = np.zeros_like(dark, dtype=np.uint8) + cv2.drawContours(out, contours, idx, 255, thickness=-1) + if preserve_holes: + child = int(hierarchy[idx][2]) + while child >= 0: + cv2.drawContours(out, contours, child, 0, thickness=-1) + child = int(hierarchy[child][0]) + component = (out > 0) & roi_mask + area = int(component.sum()) + if area >= min_area: + components.append((area, component)) + + components.sort(key=lambda item: item[0], reverse=True) + max_regions = dark_cfg.max_regions + selected = components if max_regions is None else components[:max_regions] + return [component for _, component in selected] + + +def _area_score(area_ratio: float, dark_cfg: _DarkRegionConfigLike) -> float: + """面积分项(参考 P:1298-1310,含斜率 4.0 / 系数 2.0 两个硬编码常量)。 + + 无 ``preferred_area_ratio_range`` 时 ``min(r, 1−r) × 2.0``(偏好"占一半"的暗块); + 给定区间则命中得 1.0,越界按 ``max(0, 1 − 与最近边距×4.0)`` 线性衰减。 + """ + preferred_range = dark_cfg.preferred_area_ratio_range + if preferred_range is None: + return min(area_ratio, 1.0 - area_ratio) * _AREA_SCORE_GAIN + preferred_low = float(preferred_range[0]) + preferred_high = float(preferred_range[1]) + if preferred_low <= area_ratio <= preferred_high: + return 1.0 + nearest = preferred_low if area_ratio < preferred_low else preferred_high + return max(0.0, 1.0 - abs(area_ratio - nearest) * _AREA_SLOPE) + + +def score_dark_candidate( + gray: AnyArray, + sample_mask: AnyArray, + component_masks: list[AnyArray], + previous_mask: AnyArray | None, + dark_cfg: _DarkRegionConfigLike, +) -> float: + """对一份候选暗块集打分,用于 auto_ensemble 择优(参考 P:1277-1319)。 + + 参数: + gray: 整帧灰度图。 + sample_mask: 分析采样掩膜(ROI 内、已剔绿)。 + component_masks: 候选暗块 mask 列表(``split_dark_components`` 的输出)。 + previous_mask: 上一帧选中暗部并集(无则 None),用于时间一致性。 + dark_cfg: 暗区配置(``fragment_penalty_weight`` / ``temporal_consistency_weight`` + / 可选 ``preferred_area_ratio_range``)。 + + 返回: + ``对比分 + 面积分 + 时间一致性×权重 − 碎片惩罚``;空候选 / 暗或亮像素为空 + 时返回哨兵 ``-1e9``(择优时永不胜出)。 + + 关键实现(四项逐行核对参考): + - 对比分:``max(0, (median(亮) − median(暗)) / 64.0)``(除数 64.0 硬编码)。 + - 面积分:见 ``_area_score``(斜率 4.0 / 系数 2.0)。 + - 碎片惩罚:``fragment_penalty_weight × max(0, 块数 − 1)``(块数 ≤1 不罚)。 + - 时间一致性:仅当 ``previous_mask`` 同形且非空,取 ``IoU(union, previous_mask)`` + 再乘 ``temporal_consistency_weight``。 + """ + if not component_masks: + return _EMPTY_SCORE + union = np.zeros_like(sample_mask, dtype=bool) + for component in component_masks: + union |= component + dark_values = gray[union & sample_mask] + bright_values = gray[(~union) & sample_mask] + if len(dark_values) == 0 or len(bright_values) == 0: + return _EMPTY_SCORE + + contrast_score = max( + 0.0, + (float(np.median(bright_values)) - float(np.median(dark_values))) / _CONTRAST_NORMALIZER, + ) + area_ratio = float(union.sum()) / max(1.0, float(sample_mask.sum())) + area_score = _area_score(area_ratio, dark_cfg) + + fragment_penalty = float(dark_cfg.fragment_penalty_weight) * max(0, len(component_masks) - 1) + temporal_score = 0.0 + if previous_mask is not None and previous_mask.shape == union.shape and previous_mask.any(): + temporal_score = mask_iou(union, previous_mask) + temporal_weight = float(dark_cfg.temporal_consistency_weight) + return contrast_score + area_score + temporal_score * temporal_weight - fragment_penalty + + +def _prompt_exclusion_mask( + target_bgr: AnyArray, + dark_cfg: _DarkRegionConfigLike, + prompt_cfg: _PromptHsvConfigLike, +) -> Mask: + """要从分析中剔除的绿框色像素(参考 P:1334-1345)。 + + HSV 阈值取 `prompt_cfg`(与找绿框共用同一组阈值);`prompt_exclude_dilate` 大于 1 时按方核 + 膨胀一次。 + """ + lower = np.asarray(prompt_cfg.hsv_lower, dtype=np.uint8) + upper = np.asarray(prompt_cfg.hsv_upper, dtype=np.uint8) + hsv = cv2.cvtColor(target_bgr, cv2.COLOR_BGR2HSV) + prompt_pixels: Mask = np.asarray(cv2.inRange(hsv, lower, upper)) > 0 + kernel_size = int(dark_cfg.prompt_exclude_dilate) + if kernel_size > 1: + kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8) + prompt_pixels = cv2.dilate(prompt_pixels.astype(np.uint8) * 255, kernel, iterations=1) > 0 + return prompt_pixels + + +def extract_dark_region_masks( + target_bgr: AnyArray, + roi_polygon: AnyArray, + dark_cfg: _DarkRegionConfigLike, + previous_mask: AnyArray | None, + prompt_cfg: _PromptHsvConfigLike, +) -> tuple[list[AnyArray], AnyArray | None]: + """单帧暗区提取:剔绿 → 候选法(auto_ensemble 择优)→ 拆分(参考 P:1322-1373)。 + + 参数: + target_bgr: 分析坐标系下的整帧 BGR uint8 图(``analysis_view='source'`` 即左视图局部)。 + roi_polygon: 该帧 ROI 多边形 ``(N, 2)``(分析坐标系)。 + dark_cfg: 暗区配置。 + previous_mask: 上一帧选中暗部并集(供时间一致性评分),首帧传 None。 + prompt_cfg: 剔绿用 HSV 阈值(``exclude_prompt_color=True`` 时使用)。 + + 返回: + ``(选中暗块 mask 列表, 暗块并集 mask)``;无有效 ROI / 无暗块时返回 ``([], None)``。 + + 关键实现: + ``sample_mask`` 由 ROI mask 剔除绿框像素得到(评分统计在其内),但 + ``split_dark_components`` 的面积阈与裁剪仍用**原始 roi_mask**(与参考 P:1363 一致)。 + ``threshold_method='auto_ensemble'`` 时遍历 ``candidate_methods`` 各出候选 → 拆分 → + ``score_dark_candidate`` 取最高分者;否则单法。返回的并集供下一帧 previous_mask 用。 + """ + roi_mask = polygon_to_mask(roi_polygon, target_bgr.shape[0], target_bgr.shape[1]) + if not roi_mask.any(): + return [], None + + gray = cv2.cvtColor(target_bgr, cv2.COLOR_BGR2GRAY) + sample_mask = roi_mask.copy() + if bool(dark_cfg.exclude_prompt_color): + sample_mask &= ~_prompt_exclusion_mask(target_bgr, dark_cfg, prompt_cfg) + + if len(gray[sample_mask]) == 0: + return [], None + + method = dark_cfg.threshold_method + candidate_methods = list(dark_cfg.candidate_methods) if method == "auto_ensemble" else [method] + components, union = _select_best_candidate( + gray, sample_mask, roi_mask, candidate_methods, previous_mask, dark_cfg + ) + if not components: + return [], None + return components, union + + +def _select_best_candidate( + gray: AnyArray, + sample_mask: AnyArray, + roi_mask: AnyArray, + candidate_methods: list[str], + previous_mask: AnyArray | None, + dark_cfg: _DarkRegionConfigLike, +) -> tuple[list[AnyArray], AnyArray | None]: + """对每个候选阈值法出暗块 + 评分,取最高分者(参考 P:1360-1373)。 + + 参数: + gray: 整帧灰度图。 + sample_mask: 分析采样掩膜(ROI 内、已剔绿)。 + roi_mask: 原始 ROI 掩膜(供 ``split_dark_components`` 面积阈与裁剪)。 + candidate_methods: 待评估的阈值法名列表(auto_ensemble 即全部,否则单元素)。 + previous_mask: 上一帧选中暗部并集(时间一致性评分用)。 + dark_cfg: 暗区配置。 + + 返回: + ``(最优候选的暗块列表, 其并集 mask)``;无任何候选(列表空)时并集为 None。 + + 关键实现: + 逐候选 ``dark_candidate_mask → split_dark_components → score_dark_candidate``, + ``score`` 严格大于现任最优才更换(首个候选无条件入选),并随即缓存其并集, + 避免对落选候选白算并集。空候选的评分哨兵 ``-1e9`` 保证其永不胜出。 + """ + best: tuple[float, list[AnyArray], AnyArray] | None = None + for candidate_method in candidate_methods: + dark = dark_candidate_mask(gray, sample_mask, candidate_method, dark_cfg) + components = split_dark_components(dark, roi_mask, dark_cfg) + score = score_dark_candidate(gray, sample_mask, components, previous_mask, dark_cfg) + if best is None or score > best[0]: + union = np.zeros_like(sample_mask, dtype=bool) + for component in components: + union |= component + best = (score, components, union) + if best is None: + return [], None + return best[1], best[2] + + +def extract_dark_region_polygons( + analysis_frames: Iterable[AnyArray], + roi_polygons: Sequence[AnyArray], + dark_cfg: _DarkRegionConfigLike, + prompt_cfg: _PromptHsvConfigLike, +) -> list[list[AnyArray]]: + """逐帧暗区多边形提取编排(参考 P:1376-1466,按本项目可测性裁剪视图映射)。 + + 参数: + analysis_frames: 分析坐标系下的逐帧整帧 BGR uint8 可迭代对象(``analysis_view='source'`` + 即造影那一半的局部坐标;调用方已解码并切好)。**逐帧消费、不留**, + 所以可以直接喂一个解码器的迭代器。 + roi_polygons: 与帧序列等长的逐帧 ROI 多边形(同一坐标系)。 + dark_cfg: 暗区配置。 + prompt_cfg: 剔绿用 HSV 阈值(透传给 ``extract_dark_region_masks``)。 + + 返回: + 与帧序列**等长**的逐帧暗部多边形列表(外层帧对齐,内层变长 0~N 个): + 每个多边形为 ``(contour_points, 2)`` float32(等弧长重采样)。 + ``enabled=False`` 时每帧返回空列表 ``[]``(**仍保留帧对齐**,N 帧进 N 帧出)。 + + 关键实现: + 逐帧 ``extract_dark_region_masks`` 取选中暗块,其并集作为下一帧时间一致性的 + ``previous_mask`` 串起来;对每个暗块用 ``significant_contours``(轮廓面积阈 + ``min_area×0.1``,与参考 P:1437 一致)取显著外轮廓,再 + ``resample_closed_contour`` 重采样到 ``contour_points`` 点,重采样退化(None)则跳过。 + ``max_contours_per_region`` 为 None 时不限。 + 校验帧数与多边形数一致,越界即配置缺陷直接报错(不静默对齐)——流式之后这条校验 + 由 ``zip(..., strict=True)`` 执行,因为帧数要遍历完才知道。 + """ + if not bool(dark_cfg.enabled): + # 关掉也要保持帧对齐(N 帧进 N 帧出),所以仍旧把帧走一遍,按帧数产空列表。 + return [[] for _, _ in zip(analysis_frames, roi_polygons, strict=True)] + + num_points = int(dark_cfg.contour_points) + contour_area_floor = float(dark_cfg.min_area) * 0.1 + max_contours = dark_cfg.max_contours_per_region + + dark_polygons_by_frame: list[list[AnyArray]] = [] + previous_mask: AnyArray | None = None + for frame, polygon in zip(analysis_frames, roi_polygons, strict=True): + dark_masks, selected_union = extract_dark_region_masks( + frame, polygon, dark_cfg, previous_mask, prompt_cfg + ) + previous_mask = selected_union + frame_polygons: list[AnyArray] = [] + for dark_mask in dark_masks: + for contour in _significant_contours(dark_mask, contour_area_floor, max_contours): + poly = resample_closed_contour(contour, num_points) + if poly is not None: + frame_polygons.append(poly) + dark_polygons_by_frame.append(frame_polygons) + + return dark_polygons_by_frame + + +def _significant_contours( + mask: AnyArray, + min_area: float, + max_regions: int | None, +) -> list[AnyArray]: + """取 mask 的显著外轮廓(面积降序,``min_area`` 过滤,可截断;参考 P:665-690)。 + + 参数: + mask: 单个暗块的 ``dtype=bool`` mask。 + min_area: 轮廓面积下限(``cv2.contourArea``)。 + max_regions: 保留的外轮廓数上限(None 不限)。 + + 返回: + 显著轮廓点列表(每个 ``(M, 2)`` float32,OpenCV ``CHAIN_APPROX_NONE`` 原始稠密点)。 + 含外轮廓与其下的子轮廓(洞)——外轮廓阈 ``min_area``、子轮廓阈 ``min_area×0.1``。 + + 关键实现(严格对照参考 ``significant_contours`` P:665-690): + ``RETR_CCOMP`` 取外 / 内两层;外轮廓(``hierarchy[3]<0``)面积 ``>= min_area``, + 按面积降序、``max_regions`` 截断;对每个入选外轮廓,沿 ``hierarchy`` 的 + first_child(``[idx][2]``)+ next_sibling(``[child][0]``)遍历其洞,面积 + ``>= min_area×0.1`` 的子轮廓一并追加。``split_dark_components`` 已按 + ``preserve_holes`` 决定 mask 是否含洞,故洞是否出现由 mask 自然决定。 + """ + contours, hierarchy = cv2.findContours( + mask.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE + ) + # 没有轮廓时 cv2 交回的 hierarchy 是 None。 + if not contours: + return [] + hierarchy = np.asarray(hierarchy)[0] + external = [ + (cv2.contourArea(contour), idx, contour) + for idx, contour in enumerate(contours) + if hierarchy[idx][3] < 0 and cv2.contourArea(contour) >= min_area + ] + external.sort(key=lambda item: item[0], reverse=True) + selected_external = external if max_regions is None else external[:max_regions] + selected: list[AnyArray] = [] + for _, idx, contour in selected_external: + selected.append(np.asarray(contour).reshape(-1, 2).astype(np.float32)) + child = int(hierarchy[idx][2]) + while child >= 0: + child_contour = np.asarray(contours[child]) + if cv2.contourArea(child_contour) >= min_area * 0.1: + selected.append(child_contour.reshape(-1, 2).astype(np.float32)) + child = int(hierarchy[child][0]) + return selected diff --git a/src/cadx/ceus/_overlay.py b/src/cadx/ceus/_overlay.py new file mode 100644 index 0000000..a02f1e6 --- /dev/null +++ b/src/cadx/ceus/_overlay.py @@ -0,0 +1,322 @@ +"""overlay.mp4:把病灶轮廓、暗区、背景圆叠回整幅画面逐帧渲染,供人目视检查。 + +各视图局部坐标的多边形加上所属视图的左上角偏移,画到整帧坐标上。颜色(BGR)和线宽照参考组件: +造影那一半(source)的 ROI 黄 (0,255,255)、分割那一半(target)的 ROI 绿 (0,255,0)、暗区橙红 +(255,80,20);背景圆画青 (255,255,0) 而不照参考组件画紫,理由见 `_BACKGROUND_CIRCLE_CYAN`。 +ROI 半透明填充 alpha=0.18;线条是黑色外描边加彩色内线。 + +`render_overlay` 接收已解码的逐帧整帧图像(可以是迭代器)和逐帧图层,返回 mp4 字节: +`cv2.VideoWriter` 不能直写内存,所以写临时文件、读回、在 finally 里删掉。fps 由调用方给,非正报错, +不回退 25。 + +照搬旧项目 `overlay.py`,去掉产品层;`OverlayConfig`、`OverlayLayers` 的字段默认值去掉,调用方全部 +显式给出。 +""" + +import tempfile +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np + +from cadx.ceus._geometry import AnyArray, ViewRect + +# 各层颜色(BGR,照旧项目 findings §3.10 / 参考组件 P:1469-1544)。 +_OUTLINE_BLACK = (0, 0, 0) +_SOURCE_ROI_YELLOW = (0, 255, 255) +_TARGET_ROI_GREEN = (0, 255, 0) +_DARK_ORANGE_RED = (255, 80, 20) +_BACKGROUND_CIRCLE_CYAN = (255, 255, 0) +"""自动挑的那个背景圆画成青色。**不能是紫**——紫色是医生画在同一画面上的 ``ROI_pan`` +轮廓(邻近正常胰腺实质,也就是病变旁边那块没病的胰腺腺体组织)的颜色,而这个圆是程序 +自己找的一小块干净背景,两者是不同的东西(旧项目 design/0030 决策四)。参考实现 +把它画成品红 (255,0,255),而紫色轮廓已经在算了(``tic.py`` 的 ``CURVE_NAMES`` 四条里就有 +``roi_pan``),两圈都是紫的话叠加在视频上人眼分不出哪个是参照区、哪个是医生画的—— +而**看 overlay 是第一步唯一的人工验收手段**,分不出就等于没验。 +青色和绿框、紫色轮廓在色相上都隔得开。""" +# ROI 半透明填充透明度(P:1500 硬编码 0.18)。 +_FILL_ALPHA = 0.18 + + +@dataclass(frozen=True) +class OverlayConfig: + """各画层开关与填充:画不画造影那一半的 ROI(黄)、分割那一半的 ROI(绿)、分割那一半的暗区、 + 造影那一半的暗区、背景圆(青),以及要不要对分割那一半的 ROI 做 alpha=0.18 半透明填充。""" + + draw_source_roi: bool + draw_target_polygon: bool + draw_dark_region: bool + draw_source_dark_region: bool + draw_reference_region: bool + fill_polygon: bool + + +@dataclass(frozen=True) +class OverlayLayers: + """一帧要画的各层几何(多边形都是各自视图的局部坐标)。 + + - `target_polygon`、`target_rect`:分割那一半的 ROI 和它在整帧中的矩形; + - `dark_polygons`:分割那一半的暗区多边形(可为空列表); + - `source_roi_polygon`、`source_rect`:造影那一半的 ROI 和矩形(不画时可为 None); + - `source_dark_polygons`:造影那一半的暗区多边形(可为空列表); + - `reference_region`:背景圆元数据(`enabled`、`center_xy`、`radius_px`,圆心是造影那一半的 + 局部坐标),没有时为 None。 + """ + + target_polygon: AnyArray + target_rect: ViewRect + dark_polygons: list[AnyArray] + source_roi_polygon: AnyArray | None + source_rect: ViewRect | None + source_dark_polygons: list[AnyArray] + reference_region: dict[str, Any] | None + + +def _to_global_points(polygon: AnyArray, rect: ViewRect) -> AnyArray: + """把视图局部坐标多边形抬到整帧坐标,并量化为 cv2 折线点 ``(M, 1, 2)`` int32。 + + 参数: + polygon: 形如 ``(N, 2)`` 的局部坐标顶点。 + rect: 该多边形所属视图在整帧中的矩形(提供左上角偏移)。 + + 返回: + ``cv2.polylines`` / ``cv2.fillPoly`` 直接可用的 ``(N, 1, 2)`` int32 点阵。 + + 关键实现: + 加 ``rect.x1`` / ``rect.y1`` 偏移后四舍五入取整(参考 P:1487-1489),先 copy + 避免就地污染入参多边形。 + """ + global_poly = polygon.copy().astype(np.float64) + global_poly[:, 0] += rect.x1 + global_poly[:, 1] += rect.y1 + return np.round(global_poly).astype(np.int32).reshape(-1, 1, 2) + + +def _draw_outlined_polygon( + frame: AnyArray, + points: AnyArray, + color: tuple[int, int, int], + outline_thickness: int, + color_thickness: int, +) -> None: + """就地画"黑色外描边 + 彩色内线"的闭合多边形(参考 P:1490-1491 等的双描边手法)。 + + 参数: + frame: 待绘制的整帧(就地修改)。 + points: ``(N, 1, 2)`` int32 折线点阵(整帧坐标)。 + color: 彩色内线 BGR。 + outline_thickness: 黑色外描边线宽(> 彩色线宽,作描边效果)。 + color_thickness: 彩色内线线宽。 + """ + cv2.polylines( + frame, + [points], + isClosed=True, + color=_OUTLINE_BLACK, + thickness=outline_thickness, + ) + cv2.polylines(frame, [points], isClosed=True, color=color, thickness=color_thickness) + + +def _fill_target_polygon(frame: AnyArray, points: AnyArray) -> AnyArray: + """对 target ROI 内部做 alpha=0.18 绿色半透明填充(参考 P:1497-1500)。 + + 参数: + frame: 当前帧。 + points: target ROI 整帧坐标折线点阵。 + + 返回: + 混合后的新帧(``addWeighted(fill, 0.18, frame, 0.82, 0)``)。 + + 关键实现: + 在帧副本上实心填充绿色后与原帧按 0.18 / 0.82 加权混合,得到半透明效果。 + """ + fill = frame.copy() + cv2.fillPoly(fill, [points], _TARGET_ROI_GREEN) + return cv2.addWeighted(fill, _FILL_ALPHA, frame, 1.0 - _FILL_ALPHA, 0) + + +def _draw_source_roi_layer(frame: AnyArray, layers: OverlayLayers) -> None: + """画左 / source ROI 轮廓(黄,线宽 5→2,参考 P:1481-1491)。需 source_rect。""" + if layers.source_roi_polygon is None or layers.source_rect is None: + return + points = _to_global_points(layers.source_roi_polygon, layers.source_rect) + _draw_outlined_polygon(frame, points, _SOURCE_ROI_YELLOW, 5, 2) + + +def _draw_dark_layer( + frame: AnyArray, + polygons: Sequence[AnyArray], + rect: ViewRect, + outline_thickness: int, + color_thickness: int, +) -> None: + """画一组暗区轮廓(橙红,参考 P:1504-1526);逐多边形抬到整帧再双描边。 + + 参数: + frame: 当前帧(就地修改)。 + polygons: 暗区局部坐标多边形列表。 + rect: 这些暗区所属视图矩形(target 或 source)。 + outline_thickness: 黑色外描边线宽(target 6 / source 5,参考 P:1510/1525)。 + color_thickness: 橙红内线线宽(target 3 / source 2,参考 P:1511/1526)。 + """ + for polygon in polygons: + points = _to_global_points(polygon, rect) + _draw_outlined_polygon(frame, points, _DARK_ORANGE_RED, outline_thickness, color_thickness) + + +def _draw_reference_layer(frame: AnyArray, layers: OverlayLayers) -> None: + """画 source 视图那个自动挑的背景圆(青,参考 P:1528-1543 画的是紫)。 + + 关键实现: + 仅当 ``reference_region.enabled`` 且 ``source_rect`` / ``center_xy`` / + ``radius_px`` 齐备时绘制;圆心由 source 局部坐标加 ``source_rect`` 偏移抬到整帧。 + 黑色外描边线宽 6、青色内线线宽 3(参考 P:1542-1543)。 + """ + region = layers.reference_region + if region is None or layers.source_rect is None or not region.get("enabled", False): + return + center = region.get("center_xy") + radius = region.get("radius_px") + if center is None or radius is None: + return + center_global = ( + round(float(center[0]) + layers.source_rect.x1), + round(float(center[1]) + layers.source_rect.y1), + ) + radius_int = round(float(radius)) + cv2.circle(frame, center_global, radius_int, color=_OUTLINE_BLACK, thickness=6) + cv2.circle(frame, center_global, radius_int, color=_BACKGROUND_CIRCLE_CYAN, thickness=3) + + +def draw_polygon_overlay( + frame: AnyArray, + layers: OverlayLayers, + cfg: OverlayConfig, +) -> AnyArray: + """在整帧坐标系上叠加 ROI / 暗区 / reference 各层,返回画好的新帧(不改入参)。 + + 参数: + frame: 整帧 BGR 图像 ``(H, W, 3)``。 + layers: 单帧各层几何(局部坐标多边形 + 视图矩形)。 + cfg: 各画层开关与填充配置。 + + 返回: + 叠加后的整帧副本(尺寸 / dtype 与入参一致)。 + + 关键实现: + 先 copy 帧避免污染入参;按参考 P:1469-1544 的层序绘制——source ROI(黄)→ + target ROI 填充 + 轮廓(绿)→ target 暗区(橙红)→ source 暗区(橙红)→ + 背景圆(青)。各层的局部→整帧坐标变换在 ``_to_global_points`` 内统一完成。 + 填充层(``fill_polygon``)须先于轮廓绘制,否则填充会盖住轮廓。 + """ + out = frame.copy() + + # Phase 1: source ROI 轮廓(黄)。 + if cfg.draw_source_roi: + _draw_source_roi_layer(out, layers) + + # Phase 2: target ROI——先半透明填充,再画绿色轮廓(主产物)。 + target_points = _to_global_points(layers.target_polygon, layers.target_rect) + if cfg.fill_polygon: + out = _fill_target_polygon(out, target_points) + if cfg.draw_target_polygon: + _draw_outlined_polygon(out, target_points, _TARGET_ROI_GREEN, 7, 3) + + # Phase 3: target 暗区轮廓(橙红)。 + if cfg.draw_dark_region and layers.dark_polygons: + _draw_dark_layer(out, layers.dark_polygons, layers.target_rect, 6, 3) + + # Phase 4: source 暗区轮廓(橙红)。 + if ( + cfg.draw_source_dark_region + and layers.source_dark_polygons + and layers.source_rect is not None + ): + _draw_dark_layer(out, layers.source_dark_polygons, layers.source_rect, 5, 2) + + # Phase 5: 背景圆(青)。 + if cfg.draw_reference_region: + _draw_reference_layer(out, layers) + + return out + + +def render_overlay( + frames: Iterable[AnyArray], + per_frame_layers: Sequence[OverlayLayers], + fps: float, + cfg: OverlayConfig, + *, + frame_size: tuple[int, int], +) -> bytes: + """逐帧渲染 overlay 并编码为 mp4 字节(fourcc mp4v、原帧尺寸、调用方 fps)。 + + 参数: + frames: 已解码的逐帧整帧 BGR 图像,非空且尺寸一致。**逐帧消费、不留**, + 所以可以直接喂一个解码器的迭代器。 + per_frame_layers: 与 ``frames`` 一一对应的逐帧图层;长度须等于帧数。 + fps: 视频帧率,**必须 > 0**(调用方已探测);非正直接报错,不静默回退 25.0。 + cfg: 各画层开关与填充配置。 + frame_size: 输出视频的 ``(width, height)``。**由调用方给,不再从首帧读**—— + 编码器要在写第一帧之前就建好,而流式之后「先看一眼首帧再决定尺寸」意味着 + 要么把首帧留住、要么把迭代器消费掉一帧。调用方本来就从探测拿到了这个尺寸。 + + 返回: + mp4 视频字节(mp4v 编码),由调用方写文件。 + + 异常: + ValueError: ``frames`` 为空、``frames`` 与 ``per_frame_layers`` 长度不一致 + (由 ``zip`` 的 ``strict`` 执行——流式之后帧数要遍历完才知道)、 + ``fps`` 非正,或 ``frame_size`` 非正。 + RuntimeError: ``cv2.VideoWriter`` 无法打开(编码器不可用 / 尺寸非法)。 + + 关键实现: + ``cv2.VideoWriter`` 不能直写内存,故写到临时 ``.mp4`` 文件、读回字节、``finally`` + ``unlink`` 删除(仿 ``roi_inference/frames.py`` 临时文件纪律,确保异常路径也清理); + 本模块不落本地、不碰对象存储。逐帧 ``draw_polygon_overlay`` 保持帧尺寸不变, + 故无需逐帧重校尺寸。 + """ + # Phase 1: 防御校验(不静默兜底,缺陷直接报错)。 + if float(fps) <= 0.0: + raise ValueError(f"fps 必须为正(调用方应已探测),得到 {fps}。") + width, height = frame_size + if width <= 0 or height <= 0: + raise ValueError(f"frame_size 必须为正,得到 {frame_size}。") + if not per_frame_layers: + raise ValueError("per_frame_layers 不能为空:一帧都不写产不出视频。") + + # Phase 2: 写临时 mp4,结束(含异常)后必删。 + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle: + temp_path = Path(handle.name) + try: + writer = cv2.VideoWriter( + str(temp_path), + cv2.VideoWriter.fourcc(*"mp4v"), + float(fps), + (width, height), + ) + if not writer.isOpened(): + raise RuntimeError(f"cv2.VideoWriter 无法打开(尺寸 {width}x{height}、fps {fps})。") + try: + # strict:帧数要遍历完才知道,长度不一致在这里当场抛,不静默按较短的截断。 + for index, (frame, layers) in enumerate(zip(frames, per_frame_layers, strict=True)): + if index == 0 and frame.shape[:2] != (height, width): + # **尺寸不符的帧会被 VideoWriter 静默丢掉**:实测写 6 帧读回 3 帧, + # 只在 stderr 留一行警告,不抛异常也不改返回值。结果是 overlay 视频 + # 短一截甚至是空的,而它照样有字节、照样被写成文件。 + # 尺寸以前由首帧决定所以结构上不可能不符,现在由调用方给,这条得自己守。 + raise ValueError( + f"帧尺寸 {frame.shape[:2]} 与 frame_size {(width, height)} 不符;" + "尺寸不符的帧会被编码器静默丢掉" + ) + writer.write(draw_polygon_overlay(frame, layers, cfg)) + finally: + writer.release() + + return temp_path.read_bytes() + finally: + temp_path.unlink(missing_ok=True) diff --git a/src/cadx/ceus/_perfusion_metrics.py b/src/cadx/ceus/_perfusion_metrics.py new file mode 100644 index 0000000..ddff316 --- /dev/null +++ b/src/cadx/ceus/_perfusion_metrics.py @@ -0,0 +1,151 @@ +"""第 9 步:从过了质量门的 Lognormal 拟合曲线算 11 个灌注指标(口径仿照商用造影定量软件 VueBOX)。 + +指标在 MP4 显示灰度域上算(不是线性化的回声功率),时间以视频开头为零点,面积用「拟合曲线减 +基线」。依赖观测范围的指标(如洗出段面积)在观测不够时为 None,不外推。 + +- PE:峰值增强(峰值减基线);TTP:达峰时间(D3);WiR:最大洗入斜率(D2 处导数); +- TI:洗入切线与基线的交点;RT:上升时间 TTP − TI;WiAUC:TI 到 TTP 的面积;WiPI:WiAUC / RT; +- WoR:最大洗出斜率(D4 处导数);TO:洗出切线与基线的交点;FT:下降时间 TO − TTP; +- WoAUC:TTP 到 TO 的面积;WiWoAUC:两段面积之和;mTT:TI 到 TO 之间的平均渡越时刻;mTTl:mTT − TI。 + +照搬旧项目 `perfusion_metrics.py`,去掉产品层。 +""" + +import math +from dataclasses import dataclass + +import numpy as np + +from cadx.ceus._tic_fitting import ( + LognormalFitResult, + LognormalParameters, + evaluate_lognormal, + evaluate_lognormal_derivative, +) + + +@dataclass(frozen=True) +class LognormalPerfusionMetrics: + """11 项指标及三个支撑时刻(TI、mTT、TO),单位见字段名后缀;观测不够的为 None。""" + + pe_au: float + wi_auc_au_s: float | None + rt_s: float | None + mttl_s: float | None + ttp_s: float + wir_au_per_s: float + wipi_au: float | None + wo_auc_au_s: float | None + wiwo_auc_au_s: float | None + ft_s: float | None + wor_au_per_s: float | None + ti_s: float | None + mtt_s: float | None + to_s: float | None + + +def _normal_cdf(value: float) -> float: + return 0.5 * (1.0 + math.erf(value / math.sqrt(2.0))) + + +def _elapsed_cdf(time_s: float, parameters: LognormalParameters) -> float: + elapsed_s = time_s - parameters.arrival_offset_s + if elapsed_s <= 0.0: + return 0.0 + return _normal_cdf((math.log(elapsed_s) - parameters.mu) / parameters.sigma) + + +def _elapsed_first_moment_cdf(time_s: float, parameters: LognormalParameters) -> float: + elapsed_s = time_s - parameters.arrival_offset_s + if elapsed_s <= 0.0: + return 0.0 + standardized = (math.log(elapsed_s) - parameters.mu - parameters.sigma**2) / parameters.sigma + return math.exp(parameters.mu + 0.5 * parameters.sigma**2) * _normal_cdf(standardized) + + +def _pulse_auc(start_s: float, end_s: float, parameters: LognormalParameters) -> float: + if end_s <= start_s: + raise ValueError("Lognormal 面积终点必须晚于起点") + return parameters.area * (_elapsed_cdf(end_s, parameters) - _elapsed_cdf(start_s, parameters)) + + +def _pulse_first_moment(start_s: float, end_s: float, parameters: LognormalParameters) -> float: + probability = _elapsed_cdf(end_s, parameters) - _elapsed_cdf(start_s, parameters) + elapsed_moment = _elapsed_first_moment_cdf(end_s, parameters) - _elapsed_first_moment_cdf( + start_s, parameters + ) + return parameters.area * (parameters.arrival_offset_s * probability + elapsed_moment) + + +def _observed_time(value: float, lower: float, upper: float) -> float | None: + return value if lower <= value <= upper else None + + +def compute_lognormal_perfusion_metrics( + fit: LognormalFitResult, +) -> LognormalPerfusionMetrics | None: + """拟合没过质量门时返回 None;过了就算 11 项指标,观测范围不够的那几项为 None。""" + if not fit.success: + return None + parameters = fit.parameters + if parameters is None or fit.d2_time_s is None or fit.d3_time_s is None: + raise ValueError("成功 Lognormal 拟合缺少参数或洗入/峰值时刻") + + d2_time_s = float(fit.d2_time_s) + ttp_s = float(fit.d3_time_s) + peak_value = float(evaluate_lognormal(np.asarray([ttp_s]), parameters)[0]) + pe_au = peak_value - parameters.baseline + wir_au_per_s = evaluate_lognormal_derivative(d2_time_s, parameters) + if not math.isfinite(pe_au) or pe_au <= 0.0: + raise ValueError("成功 Lognormal 拟合的峰值增强必须为有限正数") + if not math.isfinite(wir_au_per_s) or wir_au_per_s <= 0.0: + raise ValueError("成功 Lognormal 拟合的最大洗入斜率必须为有限正数") + + d2_value = float(evaluate_lognormal(np.asarray([d2_time_s]), parameters)[0]) + ti_candidate_s = d2_time_s - (d2_value - parameters.baseline) / wir_au_per_s + ti_s = _observed_time(ti_candidate_s, fit.fit_start_s, d2_time_s) + rt_s = None if ti_s is None else ttp_s - ti_s + wi_auc_au_s = None if ti_s is None else _pulse_auc(ti_s, ttp_s, parameters) + wipi_au = None if wi_auc_au_s is None or rt_s is None else wi_auc_au_s / rt_s + + wor_au_per_s: float | None = None + to_s: float | None = None + ft_s: float | None = None + wo_auc_au_s: float | None = None + if fit.d4_time_s is not None: + d4_time_s = float(fit.d4_time_s) + wor_au_per_s = evaluate_lognormal_derivative(d4_time_s, parameters) + if not math.isfinite(wor_au_per_s) or wor_au_per_s >= 0.0: + raise ValueError("成功 Lognormal 拟合的最大洗出斜率必须为有限负数") + d4_value = float(evaluate_lognormal(np.asarray([d4_time_s]), parameters)[0]) + to_candidate_s = d4_time_s - (d4_value - parameters.baseline) / wor_au_per_s + to_s = _observed_time(to_candidate_s, d4_time_s, fit.fit_end_s) + if to_s is not None: + ft_s = to_s - ttp_s + wo_auc_au_s = _pulse_auc(ttp_s, to_s, parameters) + + wiwo_auc_au_s = ( + None if wi_auc_au_s is None or wo_auc_au_s is None else wi_auc_au_s + wo_auc_au_s + ) + mtt_s: float | None = None + mttl_s: float | None = None + if ti_s is not None and to_s is not None and wiwo_auc_au_s is not None: + mtt_s = _pulse_first_moment(ti_s, to_s, parameters) / wiwo_auc_au_s + mttl_s = mtt_s - ti_s + + return LognormalPerfusionMetrics( + pe_au=pe_au, + wi_auc_au_s=wi_auc_au_s, + rt_s=rt_s, + mttl_s=mttl_s, + ttp_s=ttp_s, + wir_au_per_s=wir_au_per_s, + wipi_au=wipi_au, + wo_auc_au_s=wo_auc_au_s, + wiwo_auc_au_s=wiwo_auc_au_s, + ft_s=ft_s, + wor_au_per_s=wor_au_per_s, + ti_s=ti_s, + mtt_s=mtt_s, + to_s=to_s, + ) diff --git a/src/cadx/ceus/_pipeline.py b/src/cadx/ceus/_pipeline.py new file mode 100644 index 0000000..d14a1fc --- /dev/null +++ b/src/cadx/ceus/_pipeline.py @@ -0,0 +1,480 @@ +"""ceus 对外的两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步分割。 + +- `prepare_segmentation`(第 1–2 步):读视频、找分屏线、判断造影画面在哪一半、选提示帧,交出 + 要交给分割的那一半(灰阶那一半,`target_view`)的逐帧图像和提示帧掩膜(`target_view` 局部坐标)。 +- `quantify_case`(第 4–9 步):拿分割得到的逐帧掩膜(`target_view` 局部坐标)整理成平滑轮廓并与 + 绿框融合、挪回造影那一半,找暗区、选背景圆、逐帧采 TIC、拟合、定位 7 个关键帧、算灌注指标。 + +ceus 不引用 segment(`结构.md` 第三部分),所以分割夹在两段中间。编排顺序照旧 debug 脚本 +`t1_local_debug.py`(第 1–6 步)和 `tic_lognormal_replay.py`(第 6–9 步):旧 replay 读回轨迹后 +重新从视频采了一遍 TIC,两遍输入相同,这里只采一遍。 + +另有给第三阶段脚本写产物用的几个函数:`tic_plot_markers`(tic.png 上 D1–D4 的竖线)、 +`render_case_overlay`(overlay.mp4 的字节)、`keyframe_images`(定位到的关键帧整幅原图)、 +`quant_summary`(summary.json 里拟合、指标、关键帧那几段)。 +""" + +import math +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +from cadx.ceus._anchor_frames import ( + ANCHOR_SLOTS, + STATUS_OK, + AnchorFrameLocation, + locate_anchor_frames, +) +from cadx.ceus._config import CeusConfig +from cadx.ceus._contrast_side import ContrastSide, locate_contrast_side +from cadx.ceus._dark_region import extract_dark_region_polygons +from cadx.ceus._geometry import AnyArray, Frame, Mask, crop_frame +from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source +from cadx.ceus._overlay import OverlayConfig, OverlayLayers, render_overlay +from cadx.ceus._perfusion_metrics import ( + LognormalPerfusionMetrics, + compute_lognormal_perfusion_metrics, +) +from cadx.ceus._polygons import ( + apply_prompt_frame_position_constraints, + fuse_prompt_prior_with_medsam, + masks_to_smoothed_polygons, +) +from cadx.ceus._reference_region import select_reference_region +from cadx.ceus._split_screen import split_views +from cadx.ceus._tic import CURVE_NAMES, TicResult, compute_tic_for_video +from cadx.ceus._tic_curves import TicCurves, from_tic_result +from cadx.ceus._tic_fitting import LognormalFitResult, evaluate_lognormal +from cadx.ceus._tic_plot import TicPlotMarker +from cadx.ceus._trajectory import Trajectory +from cadx.ceus._video import VideoFile + +# 灌注指标只对病灶两条曲线算(同旧 replay 的 `_LESION_CURVES`)。 +LESION_CURVES = ("roi_all", "roi_perf") + +# tic.png 上 D1–D4 竖线的颜色(BGR,同旧 replay)。 +_ANCHOR_COLORS = { + "D1": (30, 30, 220), + "D2": (40, 150, 40), + "D3": (180, 60, 160), + "D4": (180, 130, 30), +} + +# overlay.mp4 的画层(同旧 debug 脚本 `render_overlay_bytes`):两半都画 ROI,暗区只画在造影那一半。 +_OVERLAY_CONFIG = OverlayConfig( + draw_source_roi=True, + draw_target_polygon=True, + draw_dark_region=False, + draw_source_dark_region=True, + draw_reference_region=True, + fill_polygon=False, +) + + +@dataclass(frozen=True) +class SegmentationInput: + """第一段(第 1–2 步)的结果。 + + - `video_path`、`fps`、`frame_count`、`frame_size`(宽, 高)、`frame_times_s`:视频本身; + - `split_x`:分屏线的列;`contrast`:哪一半是造影画面、两个视图矩形(整帧坐标)、提示帧 + (造影那一半局部坐标的绿框掩膜); + - `target_frames`:要交给分割的那一半(`contrast.target_view`,灰阶画面)的逐帧 BGR 图像, + 与帧号一一对应; + - `prompt_masks`:提示帧帧号 → 这一帧绿框挪到 `target_view` 局部坐标后的布尔掩膜; + - `first_contrast_frame`:造影那一半的第一帧(选背景圆用)。 + """ + + video_path: Path + fps: float + frame_count: int + frame_size: tuple[int, int] + frame_times_s: AnyArray + split_x: int + contrast: ContrastSide + target_frames: list[Frame] + prompt_masks: dict[int, Mask] + first_contrast_frame: Frame + + +@dataclass(frozen=True) +class QuantResult: + """第二段(第 4–9 步)的结果。 + + - `trajectory`:逐帧轮廓、暗区、背景圆和几何(写 trajectory.npz 用 `pack_trajectory`); + - `tic`:逐帧采样和四条曲线的基线、拟合;`curves`:同一份数据的 npz 载体(`pack_tic_curves`); + - `injection_offset_s`:推注时间 X(秒,视频第 0 秒时已推注的时长); + - `lesion_fit_ready`:病灶两条曲线的拟合是否都过了质量门; + - `anchors`:7 个关键帧槽位的定位(按 `ANCHOR_SLOTS` 顺序); + - `perfusion`:`roi_all`、`roi_perf` 各自的灌注指标,拟合没过质量门为 None; + - `pan_detected_fraction`:紫圈检出率(非重复帧里认出紫圈的帧占比)。 + """ + + trajectory: Trajectory + tic: TicResult + curves: TicCurves + injection_offset_s: float + lesion_fit_ready: bool + anchors: dict[str, AnchorFrameLocation] + perfusion: dict[str, LognormalPerfusionMetrics | None] + pan_detected_fraction: float + + +def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput: + """第 1–2 步:解码、分屏、找绿框定造影那一半、选提示帧,交出分割的输入。 + + 找不到可信分屏线抛 `SplitDetectionError`(第 1 步),两半都有或都没有绿框抛 `ContrastSideError` + (第 2 步),两者都是 `CeusCaseError`。 + """ + video = VideoFile(video_path) + fps, frame_count, width, height = video.probe() + frame_times_s = video.frame_times_s() + full_frames = [frame for _, frame in video.iter_frames()] + if len(full_frames) != frame_count: + raise RuntimeError( + f"解码帧数 {len(full_frames)} 与容器记录的帧数 {frame_count} 不一致:{video_path}" + ) + + split_x, left_view, right_view = split_views(full_frames, cfg.views) + contrast = locate_contrast_side( + full_frames, left_view, right_view, cfg.prompt_source, cfg.keyframes + ) + target_frames = [crop_frame(frame, contrast.target_view).copy() for frame in full_frames] + first_contrast_frame = crop_frame(full_frames[0], contrast.contrast_view).copy() + prompt_masks = { + prompt.frame_index: map_source_mask_to_target( + prompt.mask, contrast.contrast_view, contrast.target_view + ) + for prompt in contrast.prompt_frames + } + return SegmentationInput( + video_path=video_path, + fps=fps, + frame_count=frame_count, + frame_size=(width, height), + frame_times_s=frame_times_s, + split_x=split_x, + contrast=contrast, + target_frames=target_frames, + prompt_masks=prompt_masks, + first_contrast_frame=first_contrast_frame, + ) + + +def _contrast_frames(prepared: SegmentationInput) -> Iterator[Frame]: + """重新解码,逐帧交出造影那一半(不把整段留在内存里)。""" + for _, frame in VideoFile(prepared.video_path).iter_frames(): + yield crop_frame(frame, prepared.contrast.contrast_view) + + +def _validate_masks(prepared: SegmentationInput, target_masks: AnyArray) -> None: + """分割结果必须是 `(帧数, target 高, target 宽)` 的布尔数组。""" + view = prepared.contrast.target_view + expected = (prepared.frame_count, view.height, view.width) + if target_masks.dtype != np.bool_ or target_masks.shape != expected: + raise ValueError( + f"分割掩膜应为形状 {expected} 的布尔数组," + f"收到 {target_masks.shape} {target_masks.dtype}" + ) + + +def quantify_case( + prepared: SegmentationInput, + target_masks: AnyArray, + injection_offset_s: float, + cfg: CeusConfig, +) -> QuantResult: + """第 4–9 步:轮廓、暗区、背景圆、TIC、拟合、关键帧、灌注指标。 + + `target_masks` 是分割在 `target_view` 局部坐标上的逐帧掩膜 `(N, H, W)` 布尔数组; + `injection_offset_s` 是超声报告表的「推注开始时间s」X。分割掩膜一帧有效轮廓都没有时抛 + `NoValidContourError`(第 4 步,`CeusCaseError`)。拟合没过质量门不算失败:D1–D4 未定位、 + 灌注指标为 None。 + """ + _validate_masks(prepared, target_masks) + contrast = prepared.contrast + frame_count = prepared.frame_count + prompt_frame_dicts: list[dict[str, Any]] = [ + {"frame_index": prompt.frame_index, "mask": prompt.mask} + for prompt in contrast.prompt_frames + ] + + # 第 4 步:掩膜 → 平滑多边形 → 位置约束 → 与绿框融合 → 挪回造影那一半。 + masks_by_frame = {index: target_masks[index] for index in range(frame_count)} + target_polygons = masks_to_smoothed_polygons(masks_by_frame, frame_count, cfg.smoothing) + target_polygons = apply_prompt_frame_position_constraints( + target_polygons, + prompt_frame_dicts, + contrast.contrast_view, + contrast.target_view, + cfg.constraints.keyframe_position, + ) + target_polygons = fuse_prompt_prior_with_medsam( + target_polygons, + prompt_frame_dicts, + contrast.contrast_view, + contrast.target_view, + cfg.roi_shape, + cfg.smoothing, + ) + source_polygons = target_polygons_to_source( + target_polygons, contrast.contrast_view, contrast.target_view + ) + + # 第 5 步:暗区(在造影那一半上找)。 + dark_polygons = extract_dark_region_polygons( + _contrast_frames(prepared), + source_polygons, + cfg.interior_dark_region, + cfg.prompt_source, + ) + + # 第 6 步:背景圆、四条 TIC;第 7 步拟合在 compute_tic_for_video 里。 + source_polygon_array = np.asarray(source_polygons, dtype=np.float32) + reference_mask, reference_meta = select_reference_region( + prepared.first_contrast_frame, + source_polygon_array, + cfg.prompt_source, + cfg.pan_source, + cfg.tic_analysis, + ) + tic = compute_tic_for_video( + _contrast_frames(prepared), + source_polygons, + dark_polygons, + reference_mask, + prepared.fps, + cfg.prompt_source, + cfg.pan_source, + cfg.tic_analysis, + prepared.frame_times_s, + ) + + # 第 8 步:关键帧。 + lesion_fit_ready = all(tic.fits[name].success for name in LESION_CURVES) + frame_indices = np.arange(frame_count, dtype=np.int32) + anchors = locate_anchor_frames( + perf_fit=tic.fits["roi_perf"], + perf_baseline_end_index=tic.baselines["roi_perf"].frame_end, + lesion_fit_ready=lesion_fit_ready, + frame_indices=frame_indices, + time_video_s=prepared.frame_times_s, + injection_offset_s=injection_offset_s, + cfg=cfg.tic_analysis.anchor_frames, + ) + + # 第 9 步:灌注指标。 + perfusion = { + name: compute_lognormal_perfusion_metrics(tic.fits[name]) for name in LESION_CURVES + } + + trajectory = Trajectory( + frame_indices=frame_indices, + fps=float(prepared.fps), + target_polygons=np.asarray(target_polygons, dtype=np.float32), + source_polygons=source_polygon_array, + source_view_rect=contrast.contrast_view, + target_view_rect=contrast.target_view, + source_effective_rect=contrast.contrast_view, + target_effective_rect=contrast.target_view, + source_polygon_coordinate_space="source_view_local", + target_polygon_coordinate_space="target_effective_view_local", + reference_mask=reference_mask.astype(np.uint8), + reference_region=reference_meta, + keyframe_indices=np.asarray( + [prompt.frame_index for prompt in contrast.prompt_frames], dtype=np.int32 + ), + dark_polygons=dark_polygons, + calibration={ + "split_x": prepared.split_x, + "source": "auto_detected", + "contrast_side": contrast.side, + }, + ) + curves = from_tic_result( + tic, + fps=prepared.fps, + injection_offset_s=injection_offset_s, + config_version=cfg.config_version, + ) + return QuantResult( + trajectory=trajectory, + tic=tic, + curves=curves, + injection_offset_s=injection_offset_s, + lesion_fit_ready=lesion_fit_ready, + anchors=anchors, + perfusion=perfusion, + pan_detected_fraction=tic.pan_detected_fraction, + ) + + +def tic_plot_markers(anchors: dict[str, AnchorFrameLocation]) -> tuple[TicPlotMarker, ...]: + """tic.png 上 D1–D4 的竖线:有锚点时刻的画在锚点时刻,没定位到帧的在标签后注明状态。""" + markers: list[TicPlotMarker] = [] + for slot in ("D1", "D2", "D3", "D4"): + anchor = anchors[slot] + if anchor.anchor_time_video_s is None: + continue + status = "" if anchor.status == STATUS_OK else f" [{anchor.status}]" + markers.append( + TicPlotMarker(f"{slot} model{status}", anchor.anchor_time_video_s, _ANCHOR_COLORS[slot]) + ) + return tuple(markers) + + +def render_case_overlay(prepared: SegmentationInput, result: QuantResult) -> bytes: + """overlay.mp4 的字节:重新解码整段视频,两半都画 ROI,造影那一半画暗区和背景圆。""" + trajectory = result.trajectory + layers = [ + OverlayLayers( + target_polygon=trajectory.target_polygons[index], + target_rect=trajectory.target_view_rect, + dark_polygons=[], + source_roi_polygon=trajectory.source_polygons[index], + source_rect=trajectory.source_view_rect, + source_dark_polygons=list(trajectory.dark_polygons[index]), + reference_region=trajectory.reference_region, + ) + for index in range(prepared.frame_count) + ] + frames = (frame for _, frame in VideoFile(prepared.video_path).iter_frames()) + return render_overlay( + frames, layers, prepared.fps, _OVERLAY_CONFIG, frame_size=prepared.frame_size + ) + + +def keyframe_images( + prepared: SegmentationInput, anchors: dict[str, AnchorFrameLocation] +) -> dict[str, Frame]: + """定位到的关键帧的整幅原始画面(左右两半都在,不裁剪),槽位 → BGR 图像;未定位的槽位没有。""" + wanted: dict[int, list[str]] = {} + for slot, anchor in anchors.items(): + if anchor.frame_index is not None: + wanted.setdefault(anchor.frame_index, []).append(slot) + images: dict[str, Frame] = {} + for index, frame in VideoFile(prepared.video_path).iter_frames(): + for slot in wanted.get(index, []): + images[slot] = frame.copy() + missing = sorted(slot for slots in wanted.values() for slot in slots if slot not in images) + if missing: + raise RuntimeError(f"重新解码时没读到关键帧 {missing}:{prepared.video_path}") + return {slot: images[slot] for slot in ANCHOR_SLOTS if slot in images} + + +def _finite_or_none(value: float | None) -> float | None: + """非有限数(NaN、inf)在 JSON 里写 null。""" + if value is None or not math.isfinite(float(value)): + return None + return float(value) + + +def _fit_summary(fit: LognormalFitResult) -> dict[str, Any]: + """一条曲线的拟合摘要(同旧 replay 的 `_fit_summary`)。""" + parameters = fit.parameters + anchor_times = { + "D2": _finite_or_none(fit.d2_time_s), + "D3": _finite_or_none(fit.d3_time_s), + "D4": _finite_or_none(fit.d4_time_s), + } + anchor_values = { + slot: None + if parameters is None or time_s is None + else float(evaluate_lognormal(np.asarray([time_s]), parameters)[0]) + for slot, time_s in anchor_times.items() + } + return { + "success": fit.success, + "failure_reason": fit.failure_reason or None, + "curve_model": fit.curve_model, + "baseline_source": fit.baseline_source, + "valid_samples": int(fit.valid_sample_mask.sum()), + "duplicate_samples": int(fit.duplicate_sample_mask.sum()), + "parameters": None + if parameters is None + else { + "area": parameters.area, + "mu": parameters.mu, + "sigma": parameters.sigma, + "arrival_offset_s": parameters.arrival_offset_s, + "baseline": parameters.baseline, + }, + "quality": { + "r2": _finite_or_none(fit.r2), + "nrmse": _finite_or_none(fit.nrmse), + "residual_noise_ratio": _finite_or_none(fit.residual_noise_ratio), + "multi_peak_flag": fit.multi_peak_flag, + }, + "model_arrival_time_video_s": _finite_or_none(fit.d1_time_s), + "model_landmark_times_video_s": anchor_times, + "model_landmark_intensities": anchor_values, + } + + +def _perfusion_summary(metrics: LognormalPerfusionMetrics | None) -> dict[str, Any] | None: + """灌注指标摘要(同旧 replay 的 `_perfusion_metrics_summary`),拟合没过质量门为 None。""" + if metrics is None: + return None + return { + "signal_domain": "db_display", + "time_origin": "video_start", + "area_curve": "fitted_minus_baseline", + "values": { + "PE_au": metrics.pe_au, + "WiAUC_au_s": metrics.wi_auc_au_s, + "RT_s": metrics.rt_s, + "mTTl_s": metrics.mttl_s, + "TTP_s": metrics.ttp_s, + "WiR_au_per_s": metrics.wir_au_per_s, + "WiPI_au": metrics.wipi_au, + "WoAUC_au_s": metrics.wo_auc_au_s, + "WiWoAUC_au_s": metrics.wiwo_auc_au_s, + "FT_s": metrics.ft_s, + "WoR_au_per_s": metrics.wor_au_per_s, + }, + "supporting_times_s": { + "TI": metrics.ti_s, + "mTT": metrics.mtt_s, + "TO": metrics.to_s, + }, + } + + +def _anchor_summary(anchor: AnchorFrameLocation) -> dict[str, Any]: + """一个关键帧槽位的定位:帧号、视频时间、推注后时刻、状态、未定位原因。""" + return { + "frame_index": anchor.frame_index, + "frame_time_video_s": anchor.frame_time_video_s, + "time_since_injection_s": anchor.time_s, + "status": anchor.status, + "reason": anchor.reason, + "anchor_time_video_s": anchor.anchor_time_video_s, + "time_error_s": anchor.time_error_s, + "source_curve": anchor.source_curve, + } + + +def quant_summary(result: QuantResult) -> dict[str, Any]: + """一例的定量摘要(可直接写成 JSON):拟合、灌注指标、紫圈检出率、7 个关键帧的定位。 + + 不含视频号、路径等病例标识,由脚本按需另加。 + """ + tic = result.tic + dynamic_slots = ("B0", "D1", "D2", "D3", "D4") + return { + "injection_offset_s": result.injection_offset_s, + "reference_valid": tic.reference_valid, + "pan_detected_fraction": result.pan_detected_fraction, + "early_truncation_flag": tic.early_truncation_flag, + "lesion_fit_ready": result.lesion_fit_ready, + "fits": {name: _fit_summary(tic.fits[name]) for name in CURVE_NAMES}, + "perfusion_metrics": { + name: _perfusion_summary(result.perfusion[name]) for name in LESION_CURVES + }, + "anchor_frames": {slot: _anchor_summary(item) for slot, item in result.anchors.items()}, + "available_dynamic_slots": [ + slot for slot in dynamic_slots if result.anchors[slot].status == STATUS_OK + ], + } diff --git a/src/cadx/ceus/_polygons.py b/src/cadx/ceus/_polygons.py new file mode 100644 index 0000000..c45906f --- /dev/null +++ b/src/cadx/ceus/_polygons.py @@ -0,0 +1,357 @@ +"""第 4 步:逐帧分割掩膜 → 平滑多边形 → 位置约束 → 与绿框融合。 + +分割在灰度那一半(target_view)上做,交来的是这一半局部坐标的逐帧二值掩膜。这里把它们变成 +顶点数统一的平滑多边形轨迹,再用提示帧上的绿框(挪到 target_view 后)施加两类约束: + +- `masks_to_smoothed_polygons`:逐帧取最大外轮廓 → 等弧长重采样到 `contour_points` 个顶点 → + 与上一帧对齐起点和朝向 → 空掩膜帧由相邻帧沿时间线性插值补齐(N 帧进 N 帧出,不剔除帧)→ + 沿时间轴三角平滑; +- `apply_prompt_frame_position_constraints`:提示帧绿框质心之间线性插值出逐帧中心轨迹,把每帧 + 多边形按 `strength` 整体平移过去(不改形状和面积); +- `fuse_prompt_prior_with_medsam`:`roi_shape.source` 是 `prompt_prior` 时,由提示帧绿框推出逐帧 + 先验多边形,逐顶点混合 `多边形 = 分割 × (1 − s) + 先验 × s`(提示帧用 `keyframe_shape_strength`, + 其余帧用 `propagation_shape_strength`),再把面积收紧到先验面积的 `[min, max]` 倍 + (绿框在最终轮廓里占多少,见 `待定问题/01`)。 + +照搬旧项目 `polygons.py`,去掉产品层;坐标换算改用两个视图矩形左上角之差(`_mapping.py`), +不再需要旧的有效区矩形和映射锚点。 +""" + +from typing import Any, Protocol + +import cv2 +import numpy as np + +from cadx.ceus._errors import CeusCaseError +from cadx.ceus._geometry import ( + AnyArray, + Points, + ViewRect, + polygon_area, + polygon_center, + resample_closed_contour, + triangular_smooth, +) +from cadx.ceus._mapping import map_source_mask_to_target + + +class _SmoothingConfigLike(Protocol): + """平滑配置(`SmoothingConfig` 符合)。""" + + @property + def contour_points(self) -> int: ... + @property + def temporal_window(self) -> int: ... + + +class _RoiShapeConfigLike(Protocol): + """绿框融合配置(`RoiShapeConfig` 符合)。""" + + @property + def source(self) -> str: ... + @property + def keyframe_shape_strength(self) -> float: ... + @property + def propagation_shape_strength(self) -> float: ... + @property + def min_area_ratio_to_prompt(self) -> float: ... + @property + def max_area_ratio_to_prompt(self) -> float: ... + + +class _KeyframePositionConstraintLike(Protocol): + """提示帧位置约束配置(`KeyframePositionConstraint` 符合)。""" + + @property + def enabled(self) -> bool: ... + @property + def mode(self) -> str: ... + @property + def strength(self) -> float: ... + + +_SUPPORTED_CONSTRAINT_MODE = "center_interpolation" + +POLYGON_STEP = 4 + + +class NoValidContourError(CeusCaseError, RuntimeError): + """这一例的掩膜序列(分割结果或挪过来的绿框)一帧有效轮廓都没有,第 4 步做不下去。 + + 同时是 `RuntimeError`:旧测试按 `RuntimeError` 断言,语义不变。 + """ + + def __init__(self, reason: str) -> None: + super().__init__(POLYGON_STEP, reason) + + +def largest_contour(mask: AnyArray) -> Points | None: + """二值掩膜面积最大的外轮廓顶点 `(M, 2)` float32;没有轮廓返回 None。 + + 取全部轮廓点(`CHAIN_APPROX_NONE`),方便后面等弧长重采样。 + """ + contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) + if not contours: + return None + best = max(contours, key=cv2.contourArea) + return np.asarray(best).reshape(-1, 2).astype(np.float32) + + +def mask_center(mask: AnyArray) -> Points: + """二值掩膜前景像素的质心 `[cx, cy]`;掩膜全空时返回 (0, 0)。""" + ys, xs = np.where(mask) + if len(xs) == 0: + return np.array([0.0, 0.0], dtype=np.float32) + return np.array([float(xs.mean()), float(ys.mean())], dtype=np.float32) + + +def align_polygon_to_previous(poly: AnyArray, previous: AnyArray | None) -> AnyArray: + """把多边形的起点和朝向对齐到上一帧,让逐顶点的时间序列连续。 + + 枚举正序、逆序两种朝向 × 全部循环移位,取与上一帧逐顶点平方差最小者;不对齐的话,时间 + 平滑会把不同位置的顶点混在一起。`previous` 为 None(首帧)时原样返回。 + """ + if previous is None: + return poly + candidates = [poly, poly[::-1].copy()] + best: tuple[float, AnyArray] | None = None + for candidate in candidates: + for shift in range(len(candidate)): + shifted = np.roll(candidate, shift, axis=0) + score = float(((shifted - previous) ** 2).sum()) + if best is None or score < best[0]: + best = (score, shifted) + if best is None: + raise ValueError("多边形没有顶点,无法对齐。") + return best[1] + + +def _interpolate_polygons(polygons: list[AnyArray | None], num_points: int) -> AnyArray: + """含 None 的多边形序列沿帧轴线性插值,补齐成 `(N, num_points, 2)`。 + + 一帧有效轮廓都没有时无从插值,报 `RuntimeError`。 + """ + valid_indices = np.asarray( + [idx for idx, poly in enumerate(polygons) if poly is not None], dtype=np.int32 + ) + if len(valid_indices) == 0: + raise NoValidContourError("逐帧 mask 未产出任何有效 ROI 轮廓,无从时间插值。") + valid_values = np.asarray( + [poly.reshape(-1) for poly in polygons if poly is not None], dtype=np.float32 + ) + frame_indices = np.arange(len(polygons), dtype=np.float32) + interpolated = np.zeros((len(polygons), num_points * 2), dtype=np.float32) + for col in range(num_points * 2): + interpolated[:, col] = np.interp( + frame_indices, valid_indices.astype(np.float32), valid_values[:, col] + ) + return interpolated.reshape(len(polygons), num_points, 2) + + +def _interpolate_centers(centers_by_frame: dict[int, AnyArray], frame_count: int) -> AnyArray: + """稀疏的提示帧中心沿帧轴线性插值成逐帧中心轨迹 `(N, 2)`,两端保持端值。""" + frame_ids = np.asarray(sorted(centers_by_frame), dtype=np.float32) + centers = np.asarray( + [centers_by_frame[int(frame_id)] for frame_id in frame_ids.tolist()], dtype=np.float32 + ) + all_frames = np.arange(frame_count, dtype=np.float32) + interpolated = np.zeros((frame_count, 2), dtype=np.float32) + for col in range(2): + interpolated[:, col] = np.interp(all_frames, frame_ids, centers[:, col]) + return interpolated + + +def _clamp_polygon_to_view(polygon: AnyArray, target_rect: ViewRect) -> AnyArray: + """顶点裁到视图边界 `[0, w-1] × [0, h-1]`(返回副本)。""" + out = polygon.copy() + out[:, 0] = np.clip(out[:, 0], 0, target_rect.width - 1) + out[:, 1] = np.clip(out[:, 1], 0, target_rect.height - 1) + return out + + +def _scale_polygon_about_center(polygon: AnyArray, center: AnyArray, scale: float) -> AnyArray: + """绕给定中心等比缩放多边形。""" + return center[None, :] + (polygon - center[None, :]) * float(scale) + + +def masks_to_smoothed_polygons( + masks_by_frame: dict[int, AnyArray], + frame_count: int, + smoothing_cfg: _SmoothingConfigLike, +) -> list[AnyArray]: + """逐帧掩膜 → 顶点统一、沿时间平滑的多边形序列,长度 `frame_count`。 + + 缺帧或空掩膜视为退化帧:保留位置,由时间插值补齐。一帧有效轮廓都没有时报 `NoValidContourError`。 + """ + num_points = int(smoothing_cfg.contour_points) + temporal_window = int(smoothing_cfg.temporal_window) + + aligned: list[AnyArray | None] = [] + previous: AnyArray | None = None + for frame_idx in range(frame_count): + mask = masks_by_frame.get(frame_idx) + poly: AnyArray | None = None + if mask is not None: + poly = resample_closed_contour(largest_contour(mask), num_points) + if poly is not None: + poly = align_polygon_to_previous(poly, previous) + previous = poly + aligned.append(poly) + + interpolated = _interpolate_polygons(aligned, num_points) + smoothed = triangular_smooth(interpolated, temporal_window) + return [smoothed[idx] for idx in range(frame_count)] + + +def _prompt_frame_target_centers( + prompt_frames: list[dict[str, Any]], + source_view_rect: ViewRect, + target_view_rect: ViewRect, +) -> dict[int, AnyArray]: + """各提示帧的绿框掩膜挪到 target_view 后的质心,`帧号 → (cx, cy)`。""" + centers: dict[int, AnyArray] = {} + for prompt_frame in prompt_frames: + target_mask = map_source_mask_to_target( + prompt_frame["mask"], source_view_rect, target_view_rect + ) + centers[int(prompt_frame["frame_index"])] = mask_center(target_mask) + return centers + + +def apply_prompt_frame_position_constraints( + polygons: list[AnyArray], + prompt_frames: list[dict[str, Any]], + source_view_rect: ViewRect, + target_view_rect: ViewRect, + constraint_cfg: _KeyframePositionConstraintLike, +) -> list[AnyArray]: + """按提示帧中心插值轨迹整体平移逐帧多边形(不改形状和面积),再裁到 target_view 内。 + + `prompt_frames` 每项含 `frame_index` 和造影那一半局部坐标的绿框 `mask`。每帧位移 + = (插值中心 − 当前质心) × strength,strength 截到 [0, 1]。`enabled=False` 时原对象返回; + `mode` 不是 `center_interpolation` 报 `RuntimeError`。 + """ + if not constraint_cfg.enabled: + return polygons + + if constraint_cfg.mode != _SUPPORTED_CONSTRAINT_MODE: + raise RuntimeError( + f"constraints.keyframe_position.mode 仅支持 '{_SUPPORTED_CONSTRAINT_MODE}'," + f"得到 '{constraint_cfg.mode}'。" + ) + + strength = float(np.clip(constraint_cfg.strength, 0.0, 1.0)) + anchor_centers = _prompt_frame_target_centers(prompt_frames, source_view_rect, target_view_rect) + center_track = _interpolate_centers(anchor_centers, len(polygons)) + + corrected: list[AnyArray] = [] + for frame_idx, polygon in enumerate(polygons): + shift = (center_track[frame_idx] - polygon_center(polygon)) * strength + corrected.append(_clamp_polygon_to_view(polygon + shift[None, :], target_view_rect)) + return corrected + + +def _prompt_prior_polygons( + prompt_frames: list[dict[str, Any]], + frame_count: int, + source_view_rect: ViewRect, + target_view_rect: ViewRect, + smoothing_cfg: _SmoothingConfigLike, +) -> list[AnyArray]: + """由提示帧绿框推出逐帧先验多边形(target_view 局部坐标)。 + + 每个提示帧的绿框挪到 target_view,取最大外轮廓重采样、对齐上一帧,放在它的帧号上; + 其余帧沿时间插值、平滑;最后提示帧那几帧用原始绿框多边形覆盖(不被平滑改动)。 + """ + num_points = int(smoothing_cfg.contour_points) + temporal_window = int(smoothing_cfg.temporal_window) + + sparse: list[AnyArray | None] = [None] * frame_count + raw_key_polygons: dict[int, AnyArray] = {} + previous: AnyArray | None = None + for prompt_frame in prompt_frames: + target_mask = map_source_mask_to_target( + prompt_frame["mask"], source_view_rect, target_view_rect + ) + resampled = resample_closed_contour(largest_contour(target_mask), num_points) + if resampled is None: + continue + poly = align_polygon_to_previous(resampled, previous) + previous = poly + frame_idx = int(prompt_frame["frame_index"]) + sparse[frame_idx] = poly + raw_key_polygons[frame_idx] = poly + + interpolated = _interpolate_polygons(sparse, num_points) + if temporal_window > 1: + interpolated = triangular_smooth(interpolated, temporal_window) + for frame_idx, polygon in raw_key_polygons.items(): + interpolated[frame_idx] = polygon + return [interpolated[idx] for idx in range(frame_count)] + + +def _tighten_area_to_prior_band( + polygon: AnyArray, + prior_poly: AnyArray, + min_area_ratio: float, + max_area_ratio: float, +) -> AnyArray: + """把多边形面积收紧到先验面积的 `[min, max]` 倍之内(绕先验质心按 sqrt(目标/当前) 缩放)。 + + 先验面积或当前面积不超过 1 像素时不收紧(避免对退化面积做伪缩放);已在带内原样返回。 + """ + prior_area = polygon_area(prior_poly) + current_area = polygon_area(polygon) + if prior_area <= 1.0 or current_area <= 1.0: + return polygon + min_area = prior_area * min_area_ratio + max_area = prior_area * max_area_ratio + target_area = float(np.clip(current_area, min_area, max_area)) + if np.isclose(target_area, current_area): + return polygon + scale = float(np.sqrt(target_area / current_area)) + return _scale_polygon_about_center(polygon, polygon_center(prior_poly), scale) + + +def fuse_prompt_prior_with_medsam( + medsam_polygons: list[AnyArray], + prompt_frames: list[dict[str, Any]], + source_view_rect: ViewRect, + target_view_rect: ViewRect, + shape_cfg: _RoiShapeConfigLike, + smoothing_cfg: _SmoothingConfigLike, +) -> list[AnyArray]: + """分割多边形与绿框先验逐顶点融合并收紧面积,裁到 target_view 内。 + + `shape_cfg.source` 不是 `prompt_prior` 时原对象返回(不融合)。每帧先把分割多边形对齐到先验的 + 起点和朝向,再按权重 s(截到 [0, 1])混合。 + """ + if shape_cfg.source != "prompt_prior": + return medsam_polygons + + prior_polygons = _prompt_prior_polygons( + prompt_frames, + len(medsam_polygons), + source_view_rect, + target_view_rect, + smoothing_cfg, + ) + prompt_frame_indices = {int(prompt_frame["frame_index"]) for prompt_frame in prompt_frames} + prompt_frame_strength = float(shape_cfg.keyframe_shape_strength) + propagation_strength = float(shape_cfg.propagation_shape_strength) + min_area_ratio = float(shape_cfg.min_area_ratio_to_prompt) + max_area_ratio = float(shape_cfg.max_area_ratio_to_prompt) + + fused: list[AnyArray] = [] + for frame_idx, (medsam_poly, prior_poly) in enumerate( + zip(medsam_polygons, prior_polygons, strict=True) + ): + raw_strength = ( + prompt_frame_strength if frame_idx in prompt_frame_indices else propagation_strength + ) + strength = float(np.clip(raw_strength, 0.0, 1.0)) + aligned_medsam = align_polygon_to_previous(medsam_poly, prior_poly) + polygon = aligned_medsam * (1.0 - strength) + prior_poly * strength + polygon = _tighten_area_to_prior_band(polygon, prior_poly, min_area_ratio, max_area_ratio) + fused.append(_clamp_polygon_to_view(polygon, target_view_rect)) + return fused diff --git a/src/cadx/ceus/_reference_region.py b/src/cadx/ceus/_reference_region.py new file mode 100644 index 0000000..79b85de --- /dev/null +++ b/src/cadx/ceus/_reference_region.py @@ -0,0 +1,408 @@ +"""第 6 步之前:在造影那一半的第一帧上选背景圆(`background_circle` 曲线的采样区)。 + +背景圆是 ROI 之外一小块干净背景,用来看整机增益漂移:病灶有了 ROI 曲线还不够,还要一块同帧 +的背景作对照,才能区分「造影剂真增强」和「全局亮度漂移」。它不是紫圈(`roi_pan`):紫圈是医生 +画的正常胰腺对照,由 `_tic.py` 逐帧直接读颜色;背景圆是程序自动挑的,不喂任何口径字段。 + +`select_reference_region` 按模式分派: +- `enabled=False`:空掩膜 + `{"enabled": False}`; +- `manual`:以配置 `center_xy`(局部像素坐标)和 `radius_px` 直接画圆; +- `auto_near_roi`:从 ROI 质心沿 8 个偏好方向(左下优先)× 若干搜索半径放候选圆心,避开 ROI + 膨胀 `min_distance_px` 的禁区,候选圆内「有效背景」(非黑、不是绿框色或紫圈色)占比须 ≥ + `min_valid_fraction`,按「有效占比 − 离 ROI 远近惩罚」择优;找不到时返回空掩膜和失败原因, + 背景曲线保持 NaN。 + +搜索半径倍率和偏好方向是本模块的常量,不在配置里。照搬旧项目 `reference_region.py`,去掉产品层。 +""" + +from typing import Any, Protocol, cast + +import cv2 +import numpy as np + +from cadx.ceus._config import TicAnalysisConfig +from cadx.ceus._geometry import AnyArray, polygon_to_mask +from cadx.ceus._green_prompt import prompt_color_mask + +# auto 模式搜索半径(None 时)相对 ROI 等效尺度的倍率(参考 P:1058)。 +_SEARCH_RADIUS_FACTORS = (0.65, 0.85, 1.05, 1.25, 1.5) + +# 8 个偏好方向(左下优先,参考 P:1062):左下 / 左 / 下 / 右下 / 右 / 左上 / 上 / 右上。 +_PREFERRED_DIRECTIONS = ( + (-1.0, 1.0), + (-1.0, 0.0), + (0.0, 1.0), + (1.0, 1.0), + (1.0, 0.0), + (-1.0, -1.0), + (0.0, -1.0), + (1.0, -1.0), +) + +# 评分中"离 ROI 距离"惩罚系数(参考 P:1091,硬编码不可改)。 +_DISTANCE_PENALTY_WEIGHT = 0.05 + + +class _ReferenceRegionConfigLike(Protocol): + """背景圆配置(`ReferenceRegionConfig` 符合)。""" + + @property + def enabled(self) -> bool: ... + @property + def mode(self) -> str: ... + @property + def radius_px(self) -> int: ... + @property + def min_distance_px(self) -> int: ... + @property + def nonblack_threshold(self) -> int: ... + @property + def min_valid_fraction(self) -> float: ... + @property + def center_xy(self) -> list[float] | None: ... + + +class _PromptHsvConfigLike(Protocol): + """一组颜色的 HSV 阈值(`PromptSourceConfig`、`PanSourceConfig` 都符合)。""" + + @property + def hsv_lower(self) -> list[int]: ... + @property + def hsv_upper(self) -> list[int]: ... + + +def _circle_mask(shape: tuple[int, int], center_xy: AnyArray, radius: float) -> AnyArray: + """以 ``center_xy`` 为圆心、``radius`` 为半径栅格化实心圆 mask(参考 P:975-979)。 + + 参数: + shape: 目标 mask 形状 ``(height, width)``。 + center_xy: 圆心 ``[cx, cy]`` 像素坐标(可为浮点)。 + radius: 圆半径(像素)。 + + 返回: + ``dtype=bool`` 的 ``(height, width)`` mask,圆内(含边界,``<= r²``)为 ``True``。 + + 关键实现: + 用 ``np.ogrid`` 广播算每像素到圆心的平方距离,与 ``radius²`` 比较——闭区间 + (``<=``)与参考一致,避免边界像素被漏。 + """ + height, width = shape + yy, xx = np.ogrid[:height, :width] + cx, cy = float(center_xy[0]), float(center_xy[1]) + return (xx - cx) ** 2 + (yy - cy) ** 2 <= float(radius) ** 2 + + +def _polygon_union_mask(polygons: AnyArray, height: int, width: int) -> AnyArray: + """逐帧 ROI 多边形并集 mask(参考 P:982-986)。 + + 参数: + polygons: 逐帧 ROI 多边形序列(每帧 ``(N, 2)``)。 + height: mask 高度。 + width: mask 宽度。 + + 返回: + 所有帧 ROI 多边形按位或的 ``dtype=bool`` 并集 mask——禁区取并集而非单帧, + 保证参考圆在整段视频内都不与任何帧的 ROI 重叠。 + """ + union = np.zeros((height, width), dtype=bool) + for polygon in polygons: + union |= polygon_to_mask(polygon, height, width) + return union + + +def _forbidden_roi_mask(roi_union: AnyArray, min_distance_px: int) -> AnyArray: + """把 ROI 并集按 ``min_distance_px`` 膨胀为参考圆禁区(参考 P:1040-1045)。 + + 参数: + roi_union: ROI 并集 mask(bool)。 + min_distance_px: 参考圆与 ROI 的最小间距(像素);``<= 0`` 时不膨胀。 + + 返回: + 膨胀后的 ``dtype=bool`` 禁区 mask——参考圆不得触及其内任何像素。 + + 关键实现: + 膨胀核边长 ``2·min_distance_px + 1``(方核),与参考一致;``<= 0`` 直接返回原并集 + (禁区即 ROI 本身)。 + """ + if min_distance_px <= 0: + return roi_union + kernel = np.ones((min_distance_px * 2 + 1, min_distance_px * 2 + 1), dtype=np.uint8) + return cv2.dilate(roi_union.astype(np.uint8) * 255, kernel, iterations=1) > 0 + + +def _search_radii(roi_scale: float) -> list[float]: + """求 auto 模式的候选搜索半径列表(参考 P:1056-1058)。 + + 参数: + roi_scale: ROI 等效尺度(bbox 长 / 宽较大者,下限 1.0)。 + + 返回: + 候选圆心到 ROI 质心的距离列表:``roi_scale × _SEARCH_RADIUS_FACTORS``。 + + 关键实现: + 倍率不从配置读——``ReferenceRegionConfig`` 里没有这一项,而它是 ``extra="forbid"``, + 往 yaml 里写 ``search_radii_px`` 会在加载时被拒。要做成可配就得先进 schema。 + """ + return [roi_scale * factor for factor in _SEARCH_RADIUS_FACTORS] + + +def _preferred_directions() -> AnyArray: + """auto 模式的偏好方向数组(参考 P:1059-1065)。 + + 返回: + ``(8, 2)`` float32 方向数组(左下优先排序)——评分相同则靠前方向先入选。 + + 关键实现: + 方向表不从配置读,同 ``_search_radii``:``preferred_directions`` 不在 schema 里, + 写进 yaml 会被 ``extra="forbid"`` 拒掉。 + """ + return np.asarray(_PREFERRED_DIRECTIONS, dtype=np.float32) + + +def _candidate_centers( + roi_center: AnyArray, + search_radii: list[float], + directions: AnyArray, +) -> list[AnyArray]: + """生成 auto 模式的候选圆心列表(参考 P:1068-1073 的外两层循环)。 + + 参数: + roi_center: ROI 质心 ``[cx, cy]``(float32)。 + search_radii: 候选距离列表。 + directions: ``(K, 2)`` 偏好方向数组。 + + 返回: + 候选圆心 ``[cx, cy]`` 列表——半径 × 方向(单位化)双重笛卡尔积;零向量跳过。 + 外层遍历距离、内层遍历方向,保持与参考一致的先小半径、左下优先的枚举顺序。 + """ + centers: list[AnyArray] = [] + for distance in search_radii: + for row in range(directions.shape[0]): + direction = directions[row] + norm = float(cast(float, np.linalg.norm(direction))) + if norm <= 1e-6: + continue + centers.append(roi_center + direction / norm * float(distance)) + return centers + + +def _circle_within_bounds(center: AnyArray, radius: float, height: int, width: int) -> bool: + """判断以 ``center`` 为心、``radius`` 为半径的圆是否完整落在画面内(参考 P:1074-1079)。""" + return not ( + center[0] - radius < 0 + or center[1] - radius < 0 + or center[0] + radius >= width + or center[1] + radius >= height + ) + + +def _valid_fraction(mask: AnyArray, valid_background: AnyArray) -> float: + """候选圆内"有效背景"像素占比(参考 P:1085)。 + + 参数: + mask: 候选圆 mask(bool)。 + valid_background: 有效背景 mask(非黑、非医生画的线、非禁区,bool)。 + + 返回: + ``(圆 ∩ 有效背景) / 圆面积``;圆面积为 0 时返回 0.0(不会被选中)。 + """ + area = int(mask.sum()) + if area == 0: + return 0.0 + return float((mask & valid_background).sum()) / float(area) + + +def _score_candidate( + center: AnyArray, + valid_fraction: float, + first_polygon: AnyArray, + roi_scale: float, +) -> float: + """候选圆评分:有效占比越高越好,离 ROI 越远略减分(参考 P:1088-1091)。 + + 参数: + center: 候选圆心 ``[cx, cy]``。 + valid_fraction: 该候选圆内有效背景占比。 + first_polygon: 第一帧 ROI 多边形(``(N, 2)``),用于算到 ROI 的最近点距离。 + roi_scale: ROI 等效尺度(归一化距离用,下限 1.0)。 + + 返回: + ``valid_fraction − 到ROI最近距离 / roi_scale × 0.05``——倾向"贴近 ROI 的干净背景" + (同帧、就近,光照 / 探头条件最一致),但有效占比仍是主导项。 + """ + distance_to_roi = float(np.sqrt(((center[None, :] - first_polygon) ** 2).sum(axis=1)).min()) + return valid_fraction - distance_to_roi / max(roi_scale, 1.0) * _DISTANCE_PENALTY_WEIGHT + + +def _roi_scale(first_polygon: AnyArray) -> float: + """ROI 等效尺度:bbox 长 / 宽中较大者,下限 1.0(参考 P:1053-1055)。""" + bbox_min = first_polygon.min(axis=0) + bbox_max = first_polygon.max(axis=0) + return max(float(bbox_max[0] - bbox_min[0]), float(bbox_max[1] - bbox_min[1]), 1.0) + + +def _manual_reference( + shape: tuple[int, int], ref_cfg: _ReferenceRegionConfigLike, radius: float +) -> tuple[AnyArray, dict[str, Any]]: + """manual 模式:以配置 ``center_xy`` + ``radius`` 直接出圆 + metadata(参考 P:1021-1033)。 + + 参数: + shape: 第一帧 ``(height, width)``。 + ref_cfg: 参考区配置(取 ``center_xy``)。 + radius: 圆半径(像素)。 + + 返回: + ``(圆 mask, metadata dict)``;metadata 含 enabled/mode/shape/center_xy/radius_px/area_px。 + + 关键实现: + ``center_xy`` 形状必须为 ``(2,)``,否则视为配置缺陷直接报错(不静默兜底)。schema + 的 ``model_validator`` 已强制 manual 模式 center_xy 非空,这里再校验维度做纵深防御。 + """ + center = np.asarray(ref_cfg.center_xy, dtype=np.float32) + if center.shape != (2,): + raise RuntimeError("tic_analysis.reference_region.center_xy 必须是 [x, y]。") + mask = _circle_mask(shape, center, radius) + return mask, { + "enabled": True, + "mode": "manual", + "shape": "circle", + "center_xy": [float(center[0]), float(center[1])], + "radius_px": radius, + "area_px": int(mask.sum()), + } + + +def _auto_reference( + first_source_bgr: AnyArray, + source_polygons: AnyArray, + ref_cfg: _ReferenceRegionConfigLike, + prompt_cfg: _PromptHsvConfigLike, + pan_cfg: _PromptHsvConfigLike, + tic_cfg: TicAnalysisConfig, + radius: float, +) -> tuple[AnyArray, dict[str, Any]]: + """auto_near_roi 模式:ROI 外搜索一块有效背景圆择优(参考 P:1038-1109)。 + + 参数: + first_source_bgr: 第一帧 source 视图 BGR uint8 ``(h, w, 3)``(已是 source 局部坐标)。 + source_polygons: 逐帧 ROI 多边形序列(禁区取并集,避开整段 ROI)。 + ref_cfg: 参考区配置。 + prompt_cfg: 剔绿 HSV 阈值(透传给 ``prompt_color_mask``)。 + pan_cfg: 剔紫 HSV 阈值(同一路传法,见「关键实现」为什么紫色也要排)。 + tic_cfg: TIC 配置(剔绿开关 / 膨胀核,透传给 ``prompt_color_mask``)。 + radius: 圆半径(像素)。 + + 返回: + ``(选中圆 mask, metadata dict)``;metadata 额外含 ``valid_fraction_first_frame``。 + + 关键实现: + 有效背景 = 非黑(灰度 > ``nonblack_threshold``)且不是医生画上去的线(绿框色与 + 紫色轮廓色都排掉)且非禁区。逐候选圆需满足:完整在画面内、不触禁区、有效占比 + ≥ ``min_valid_fraction``,再按 ``_score_candidate`` 取最高分。无合规候选返回空掩膜和 + ``failure_reason=no_valid_candidate``;背景曲线据此保持 NaN,不阻断病灶拟合。 + + **紫色和绿色一起排。** 紫色轮廓是医生画的邻近正常胰腺实质参照区,TIC 采样时把 + 这些像素从掩膜里扣掉(``tic._sample_frame_row`` 的排除色 = 绿 ∪ 紫)。这里若只 + 排绿,挑中的背景圆可能压在那圈紫色上,``valid_background_circle_px`` 被悄悄削掉 + 一块,而没有任何一处会报出来——紫色的灰度不低,``nonblack_threshold`` 也拦不住它。 + """ + height, width = first_source_bgr.shape[:2] + roi_union = _polygon_union_mask(source_polygons, height, width) + forbidden_roi = _forbidden_roi_mask(roi_union, int(ref_cfg.min_distance_px)) + + ink_mask = prompt_color_mask(first_source_bgr, prompt_cfg, tic_cfg) | prompt_color_mask( + first_source_bgr, pan_cfg, tic_cfg + ) + gray = cv2.cvtColor(first_source_bgr, cv2.COLOR_BGR2GRAY) + nonblack = gray > int(ref_cfg.nonblack_threshold) + valid_background = nonblack & ~ink_mask & ~forbidden_roi + + first_polygon = source_polygons[0] + roi_center = first_polygon.mean(axis=0).astype(np.float32) + roi_scale = _roi_scale(first_polygon) + search_radii = _search_radii(roi_scale) + directions = _preferred_directions() + min_valid_fraction = float(ref_cfg.min_valid_fraction) + + best: tuple[float, AnyArray, AnyArray, float] | None = None + for candidate_center in _candidate_centers(roi_center, search_radii, directions): + if not _circle_within_bounds(candidate_center, radius, height, width): + continue + mask = _circle_mask((height, width), candidate_center, radius) + if int(mask.sum()) == 0 or np.any(mask & forbidden_roi): + continue + valid_fraction = _valid_fraction(mask, valid_background) + if valid_fraction < min_valid_fraction: + continue + score = _score_candidate(candidate_center, valid_fraction, first_polygon, roi_scale) + if best is None or score > best[0]: + best = (score, candidate_center, mask, valid_fraction) + + if best is None: + return np.zeros((height, width), dtype=bool), { + "enabled": True, + "mode": "auto_near_roi", + "shape": "circle", + "available": False, + "failure_reason": "no_valid_candidate", + } + + _, selected_center, selected_mask, selected_fraction = best + return selected_mask, { + "enabled": True, + "mode": "auto_near_roi", + "shape": "circle", + "center_xy": [float(selected_center[0]), float(selected_center[1])], + "radius_px": radius, + "area_px": int(selected_mask.sum()), + "valid_fraction_first_frame": float(selected_fraction), + } + + +def select_reference_region( + first_source_bgr: AnyArray, + source_polygons: AnyArray, + prompt_cfg: _PromptHsvConfigLike, + pan_cfg: _PromptHsvConfigLike, + tic_cfg: TicAnalysisConfig, +) -> tuple[AnyArray, dict[str, Any]]: + """在第一帧 source 视图上选定 TIC 参考区圆,出 mask + 元数据(参考 P:1007-1109)。 + + 参数: + first_source_bgr: 第一帧 source 视图 BGR uint8 ``(h, w, 3)``(已是 source 局部坐标)。 + source_polygons: 逐帧 ROI 多边形序列 ``(T, N, 2)``(参考区只看第一帧定位,禁区取整段并集)。 + prompt_cfg: 绿框 HSV 阈值配置(剔绿用,透传 ``prompt_color_mask``)。 + pan_cfg: 紫色轮廓 HSV 阈值配置(剔紫用,同一路传法)。 + tic_cfg: TIC 分析配置;从中取 ``reference_region`` 子配置与剔绿开关 / 膨胀核。 + + 返回: + ``(reference mask, metadata dict)``—— + mask:``dtype=bool`` 的 ``(h, w)`` 圆 mask(TIC 据此采样 ``background_circle`` 曲线); + metadata:写进 trajectory.npz 的 ``reference_region_json``。各模式的键集: + - enabled=False → ``{"enabled": False}``(空 mask); + - manual → ``{enabled, mode, shape, center_xy, radius_px, area_px}``; + - auto_near_roi → 额外含 ``valid_fraction_first_frame``。 + ``shape`` 恒为 ``"circle"``(出自旧项目 findings §2B:输出死键,不从配置读)。 + + 异常: + RuntimeError: ``mode`` 非法,或 manual 的 center_xy 维度非法。 + + 关键实现: + 三模式分派——关闭直接空返;manual 直接画圆;auto 走 ``_auto_reference`` 搜索择优。 + 参考区配置经 ``tic_cfg.reference_region`` 取出,不在本模块写默认值。 + """ + ref_cfg = tic_cfg.reference_region + + if not bool(ref_cfg.enabled): + empty = np.zeros(first_source_bgr.shape[:2], dtype=bool) + return empty, {"enabled": False} + + radius = float(ref_cfg.radius_px) + mode = ref_cfg.mode + if mode == "manual": + return _manual_reference(first_source_bgr.shape[:2], ref_cfg, radius) + if mode != "auto_near_roi": + raise RuntimeError(f"不支持的 tic_analysis.reference_region.mode: {mode}") + return _auto_reference( + first_source_bgr, source_polygons, ref_cfg, prompt_cfg, pan_cfg, tic_cfg, radius + ) diff --git a/src/cadx/ceus/_tic.py b/src/cadx/ceus/_tic.py new file mode 100644 index 0000000..840fa91 --- /dev/null +++ b/src/cadx/ceus/_tic.py @@ -0,0 +1,438 @@ +"""第 6 步:TIC 四条曲线的逐帧采样、增强前基线,以及(调第 7 步)Lognormal 拟合。 + +TIC(时间-强度曲线)是病灶亮度随时间的变化。在造影那一半画面(局部坐标)上逐帧采四条 +灰度均值曲线: + +- `roi_all`:病灶 ROI 内(剔除绿框色与紫圈色后)的灰度均值; +- `roi_perf`:在 `roi_all` 基础上再剔除暗区后的灰度均值(perf 指灌注,见 `_tic_curves.py`); +- `roi_pan`:医生画的紫圈内部(一块正常胰腺,作对照); +- `background_circle`:程序自动挑的一小块背景圆,只用来看整机增益漂移,不是 `roi_pan`。 + +掩膜构成: + + exclude = 绿框色 ∪ 紫圈色 + valid_roi_all = roi_mask & ~exclude + valid_roi_perf = valid_roi_all & ~dark_union + valid_pan = 紫圈填充区 & ~exclude + valid_background = background_circle_mask & ~exclude + +四条原始曲线采好后,每条各自识别增强前基线(`detect_baseline`),再交给 `_tic_fitting` 拟合。 +曲线最前面不插「时间 0、值 0」的合成点:那个点不是量出来的,会在它与首个真实帧之间造出 +一段最陡的假上升,把「变亮最快」的时刻钉在视频开头。 + +照搬旧项目 `tic.py`,去掉产品层;另加 `pan_detected_fraction`(紫圈检出率,任务书 02 要报)。 +""" + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import Any + +import cv2 +import numpy as np + +from cadx.ceus._config import ( + PanSourceConfig, + PromptSourceConfig, + TicAnalysisConfig, + TicBaselineConfig, +) +from cadx.ceus._geometry import AnyArray, Frame, Mask, polygon_to_mask, polygons_to_mask +from cadx.ceus._green_prompt import extract_color_prompt, prompt_color_mask +from cadx.ceus._tic_fitting import LognormalFitResult, fit_lognormal_curve + +__all__ = [ + "CURVE_NAMES", + "BaselineResult", + "TicResult", + "compute_tic_for_video", + "detect_baseline", + "mean_or_nan", +] + +# 四条曲线的名字;逐帧行键按 `{name}_raw` / `{name}_processed` 等派生。 +CURVE_NAMES = ("roi_all", "roi_perf", "roi_pan", "background_circle") + +# 临床口径认得的三条。背景圆不在里面:它不喂任何口径字段,基线被截短的质控位也不由它置位。 +_SPEC_CURVE_NAMES = ("roi_all", "roi_perf", "roi_pan") + + +@dataclass(frozen=True) +class BaselineResult: + """一条曲线的增强前基线区间(还没被造影剂点亮的那一段)。 + + `mean`、`sd` 是区间内的稳健中心和噪声(中位数、1.4826×MAD),`valid=False` 时为 None; + `frame_start` 含、`frame_end` 不含,无效时都是 -1。无效时用 None 而不用 0.0:0.0 能一路 + 算下去把下游悄悄算错,None 会在第一个用它的地方报错。 + """ + + mean: float | None + sd: float | None + frame_start: int + frame_end: int + valid: bool + + +@dataclass(frozen=True) +class TicResult: + """TIC 采样与拟合的结果。 + + - `rows`:逐帧一行(只有真实帧),键见 `compute_tic_for_video`; + - `parameters`:定量参数,这里恒为 None(灌注指标由 `_perfusion_metrics` 从拟合算); + - `baselines`、`fits`:曲线名 → 基线 / 拟合,键集恒为 `CURVE_NAMES`; + - `early_truncation_flag`:口径三条曲线里有一条采到过值、却凑不出足够长的基线; + - `reference_valid`:紫圈检出率达标,`roi_pan` 可用;为 False 时 `roi_pan` 整条 NaN; + - `pan_detected_fraction`:非重复帧里真正采到紫圈像素的帧占比(紫圈检出率)。 + """ + + rows: list[dict[str, Any]] + parameters: dict[str, Any] | None + baselines: dict[str, BaselineResult] + fits: dict[str, LognormalFitResult] + early_truncation_flag: bool + reference_valid: bool + pan_detected_fraction: float + + +def mean_or_nan(gray: AnyArray, mask: AnyArray) -> float: + """掩膜内灰度均值;掩膜全空时返回 NaN(这一帧这条曲线没采到,不当作亮度 0)。""" + values = gray[mask] + if len(values) == 0: + return float("nan") + return float(values.mean()) + + +_INVALID_BASELINE = BaselineResult(mean=None, sd=None, frame_start=-1, frame_end=-1, valid=False) + + +def _robust_center_noise(values: AnyArray) -> tuple[float, float]: + """中位数与 1.4826×MAD(正态下等于标准差的稳健估计)。""" + center = float(np.median(values)) + noise = 1.4826 * float(np.median(np.abs(values - center))) + return center, noise + + +def _window_is_stable(window: AnyArray, sd_multiplier: float) -> bool | None: + """候选窗对半分,后半中位数有没有明显高过前半。 + + 稳定返回 True、已在上涨返回 False;任一半没有有效采样、判不出来时返回 None。 + """ + half = len(window) // 2 + first = window[:half] + second = window[half:] + first = first[np.isfinite(first)] + second = second[np.isfinite(second)] + if first.size == 0 or second.size == 0: + return None + first_center, first_noise = _robust_center_noise(first) + second_center = float(np.median(second)) + first_differences = np.diff(first) + if first_differences.size: + difference_center = float(np.median(first_differences)) + difference_noise = 1.4826 * float(np.median(np.abs(first_differences - difference_center))) + else: + difference_noise = first_noise + return bool( + second_center - first_center + <= sd_multiplier * max(difference_noise, float(np.finfo(np.float64).eps)) + ) + + +def detect_baseline( + values: AnyArray, + time_s: AnyArray, + cfg: TicBaselineConfig, + sample_mask: AnyArray | None, +) -> BaselineResult: + """在曲线开头按真实时间找增强前基线。 + + 候选窗从视频开头覆盖 `preferred_duration_s` 秒;对半分后后半中位数比前半高出超过 + `stability_sd_multiplier × 前半差分噪声`,就从右侧逐个样本缩窗;有效样本覆盖的时长不足 + `minimum_duration_s` 时基线无效。NaN 帧和 `sample_mask` 为 False 的帧不算基线样本 + (`sample_mask` 为 None 表示都算)。 + + 这一步只出基线均值和噪声,不出到达时刻和峰值:到达门槛含峰高,要先找峰,所以到达时刻 + 在拟合那一步回头定。 + """ + series = np.asarray(values, dtype=np.float64) + times = np.asarray(time_s, dtype=np.float64) + mask = ( + np.ones(series.shape, dtype=bool) + if sample_mask is None + else np.asarray(sample_mask, dtype=bool) + ) + if series.ndim != 1 or times.shape != series.shape or mask.shape != series.shape: + raise ValueError("values、time_s 与 sample_mask 必须是等长一维数组。") + if series.size == 0 or not np.isfinite(times).all() or np.any(np.diff(times) <= 0): + raise ValueError("time_s 必须非空、有限且严格递增。") + + window_end_s = float(times[0]) + cfg.preferred_duration_s + end = int(np.searchsorted(times, window_end_s, side="left")) + end = min(max(end, 1), int(series.size)) + cadence = float(np.median(np.diff(times))) if series.size > 1 else 0.0 + + while end > 0: + window_mask = np.isfinite(series[:end]) & mask[:end] + finite_indices = np.flatnonzero(window_mask) + if finite_indices.size < 2: + return _INVALID_BASELINE + observed_duration = float(times[finite_indices[-1]] - times[finite_indices[0]]) + cadence + if observed_duration + 1e-9 < cfg.minimum_duration_s: + return _INVALID_BASELINE + window = np.where(window_mask, series[:end], np.nan) + stable = _window_is_stable(window, cfg.stability_sd_multiplier) + if stable is None: + return _INVALID_BASELINE + if stable: + finite = series[:end][window_mask] + center, noise = _robust_center_noise(finite) + return BaselineResult( + mean=center, + sd=noise, + frame_start=0, + frame_end=end, + valid=True, + ) + end -= 1 + return _INVALID_BASELINE + + +def _sample_frame_row( + frame_index: int, + time_s: float, + source_bgr: Frame, + roi_polygon: AnyArray, + frame_dark_polygons: Sequence[AnyArray], + background_circle_mask: Mask, + prompt_cfg: PromptSourceConfig, + pan_cfg: PanSourceConfig, + tic_cfg: TicAnalysisConfig, +) -> tuple[dict[str, Any], AnyArray]: + """一帧的四掩膜采样,返回这一帧的原始行和判断相邻重复帧用的 ROI 灰度签名。 + + 紫圈的线本身是医生画的,不是组织,所以排除色是「绿 ∪ 紫」。`roi_pan` 取紫圈实心填充 + 再减排除色(轮廓线本身不算参照区内部),这一帧检不到紫圈时 `pan_area_px=0`、 + `roi_pan_raw` 为 NaN;紫圈不走分割、不做传播,画面上每帧都有,直接读。 + + `pan_area_px` 是紫圈填充面积(未扣排除色),`valid_pan_px` 是扣完后真正参与统计的像素数; + 检出率数后者。暗区多边形一次奇偶填充成掩膜(洞不计回暗区),再收口到 `valid_roi_all`; + 收口后暗区面积为 0 时暗块计数也记 0。 + """ + height, width = source_bgr.shape[:2] + gray = np.asarray(cv2.cvtColor(source_bgr, cv2.COLOR_BGR2GRAY)) + exclude_mask = prompt_color_mask(source_bgr, prompt_cfg, tic_cfg) | prompt_color_mask( + source_bgr, pan_cfg, tic_cfg + ) + + roi_mask = polygon_to_mask(roi_polygon, height, width) + valid_roi_all_mask = roi_mask & ~exclude_mask + valid_background_mask = background_circle_mask & ~exclude_mask + + dark_union = polygons_to_mask(frame_dark_polygons, height, width) + dark_union &= valid_roi_all_mask + valid_roi_perf_mask = valid_roi_all_mask & ~dark_union + + pan_prompt = extract_color_prompt(source_bgr, pan_cfg) + if pan_prompt is None: + pan_mask = np.zeros((height, width), dtype=bool) + pan_area_px = 0 + else: + pan_mask = pan_prompt.mask & ~exclude_mask + pan_area_px = int(pan_prompt.mask.sum()) + + dark_area_px = int(dark_union.sum()) + dark_component_count = len(frame_dark_polygons) if dark_area_px > 0 else 0 + + x_coord, y_coord, box_width, box_height = cv2.boundingRect( + np.asarray(roi_polygon, dtype=np.float32) + ) + roi_gray = gray[y_coord : y_coord + box_height, x_coord : x_coord + box_width] + roi_crop_mask = roi_mask[y_coord : y_coord + box_height, x_coord : x_coord + box_width] + signature_gray = np.asarray(cv2.resize(roi_gray, (32, 32), interpolation=cv2.INTER_AREA)) + signature_mask = np.asarray( + cv2.resize(roi_crop_mask.astype(np.uint8), (32, 32), interpolation=cv2.INTER_NEAREST) + ).astype(bool) + signature = np.where(signature_mask, signature_gray, 0).astype(np.float32) + + row: dict[str, Any] = { + "frame_index": frame_index, + "time_sec": float(time_s), + "roi_all_raw": mean_or_nan(gray, valid_roi_all_mask), + "roi_perf_raw": mean_or_nan(gray, valid_roi_perf_mask), + "roi_pan_raw": mean_or_nan(gray, pan_mask), + "background_circle_raw": mean_or_nan(gray, valid_background_mask), + "roi_area_px": int(roi_mask.sum()), + "dark_area_px": dark_area_px, + "pan_area_px": pan_area_px, + "background_circle_area_px": int(background_circle_mask.sum()), + "valid_roi_all_px": int(valid_roi_all_mask.sum()), + "valid_roi_perf_px": int(valid_roi_perf_mask.sum()), + "valid_pan_px": int(pan_mask.sum()), + "valid_background_circle_px": int(valid_background_mask.sum()), + "dark_component_count": dark_component_count, + } + return row, signature + + +def _attach_baselines_and_fits( + rows: list[dict[str, Any]], tic_cfg: TicAnalysisConfig +) -> tuple[dict[str, BaselineResult], dict[str, LognormalFitResult], bool]: + """逐条曲线识别基线并拟合,把候选、拟合、正式曲线原地写回逐帧行。 + + 每条曲线各算各的基线窗:胰腺比病灶早增强,共用一个窗会把 `roi_pan` 的窗拖进已在上涨的区间。 + `roi_perf` 只用「去暗区面积占比与上一帧相比变化不超过 `maximum_perf_area_step_fraction`」 + 的帧(`perf_mask_stable`),暗区掩膜跳变的帧不进基线和拟合。 + + `early_truncation_flag` 只看口径三条:某条采到过实测值、却凑不出足够长的基线就置位; + 整条 NaN 的曲线不算(那是没检出,由 `reference_valid` 管);背景圆不参与。 + """ + baselines: dict[str, BaselineResult] = {} + fits: dict[str, LognormalFitResult] = {} + early_truncation_flag = False + times = np.asarray([row["time_sec"] for row in rows], dtype=np.float64) + duplicates = np.asarray([row["duplicate_frame"] for row in rows], dtype=bool) + roi_areas = np.asarray([row["roi_area_px"] for row in rows], dtype=np.float64) + perf_areas = np.asarray([row["valid_roi_perf_px"] for row in rows], dtype=np.float64) + perf_fractions = np.divide( + perf_areas, + roi_areas, + out=np.zeros_like(perf_areas), + where=roi_areas > 0.0, + ) + perf_mask_stable = roi_areas > 0.0 + if len(rows) > 1: + stable_steps = ( + np.abs(np.diff(perf_fractions)) <= tic_cfg.fitting.maximum_perf_area_step_fraction + ) + perf_mask_stable[1:] &= stable_steps + for index, row in enumerate(rows): + row["perf_mask_stable"] = bool(perf_mask_stable[index]) + for name in CURVE_NAMES: + raw = np.asarray([row[f"{name}_raw"] for row in rows], dtype=np.float64) + curve_mask = perf_mask_stable if name == "roi_perf" else np.ones(len(rows), dtype=bool) + baseline = detect_baseline(raw, times, tic_cfg.baseline, ~duplicates & curve_mask) + baselines[name] = baseline + if name in _SPEC_CURVE_NAMES and not baseline.valid and bool(np.isfinite(raw).any()): + early_truncation_flag = True + fit = fit_lognormal_curve( + times, + raw, + baseline=baseline.mean, + baseline_noise=baseline.sd, + baseline_end_index=baseline.frame_end, + duplicate_mask=duplicates, + sample_mask=curve_mask, + cfg=tic_cfg.fitting, + anchor_cfg=tic_cfg.anchor_frames, + ) + fits[name] = fit + for idx, row in enumerate(rows): + row[f"{name}_candidate"] = float(fit.candidate[idx]) + row[f"{name}_fitted"] = float(fit.fitted[idx]) + row[f"{name}_processed"] = float(fit.processed[idx]) + return baselines, fits, early_truncation_flag + + +def compute_tic_for_video( + source_frames: Iterable[Frame], + roi_polygons: Sequence[AnyArray], + dark_polygons_per_frame: Sequence[Sequence[AnyArray]], + reference_mask: AnyArray, + fps: float, + prompt_cfg: PromptSourceConfig, + pan_cfg: PanSourceConfig, + tic_cfg: TicAnalysisConfig, + frame_times_s: Sequence[float] | AnyArray | None, +) -> TicResult: + """逐帧采四掩膜灰度 → 标记重复帧 → 基线与 Lognormal 拟合,交出 `TicResult`。 + + - `source_frames`:造影那一半的逐帧 BGR(局部坐标),逐帧消费、不留,可以直接喂解码迭代器; + - `roi_polygons`、`dark_polygons_per_frame`:与帧等长的逐帧 ROI 多边形和暗区多边形列表; + - `reference_mask`:背景圆掩膜(第一帧定位、整段沿用); + - `frame_times_s`:逐帧显示时间戳(秒);为 None 时按 `fps` 构造等间隔时间轴(只给合成测试用)。 + + `rows` 每行键:`frame_index`、`time_sec`、`duplicate_frame`、`perf_mask_stable`,四条曲线各自的 + `_raw`、`_candidate`、`_fitted`、`_processed`,以及各掩膜面积和有效像素数、暗块计数。 + + 紫圈检出率 = 非重复帧里 `valid_pan_px > 0` 的帧占比(数的是真正参与统计的像素,不是填充面积: + 一整块实心紫色会让填充面积过门槛而扣完排除色一个像素不剩)。达到 + `pan_cfg.min_detected_frame_fraction` 才算紫圈可用;不达标时 `roi_pan` 整条记 NaN、基线无效, + 不拿背景圆去填(两者不是同一种东西)。 + """ + if len(roi_polygons) != len(dark_polygons_per_frame): + raise ValueError( + "roi_polygons 与 dark_polygons_per_frame 长度必须一致,得到 " + f"{len(roi_polygons)} / {len(dark_polygons_per_frame)}。" + ) + if float(fps) <= 0.0: + raise ValueError(f"fps 必须为正,得到 {fps}。") + if tic_cfg.brightness_metric != "grayscale_mean": + raise ValueError( + f"仅支持 brightness_metric=grayscale_mean,得到 {tic_cfg.brightness_metric}。" + ) + background_circle_mask = reference_mask.astype(bool) + if frame_times_s is None: + times = np.arange(len(roi_polygons), dtype=np.float64) / float(fps) + else: + times = np.asarray(frame_times_s, dtype=np.float64) + if times.shape != (len(roi_polygons),): + raise ValueError( + "frame_times_s 必须与 roi_polygons 等长,得到 " + f"{times.shape} / {len(roi_polygons)}。" + ) + if not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0): + raise ValueError("frame_times_s 必须有限且严格递增。") + + # 帧是流式的,长度要遍历完才知道:zip(strict=True) 保证三个序列等长,多了少了当场报错。 + rows: list[dict[str, Any]] = [] + previous_unique_signature: AnyArray | None = None + for frame_index, (frame, roi_polygon, dark_polygons) in enumerate( + zip(source_frames, roi_polygons, dark_polygons_per_frame, strict=True) + ): + if frame_index == 0 and background_circle_mask.shape != frame.shape[:2]: + raise ValueError( + f"reference_mask 形状 {background_circle_mask.shape} 与 source 帧 " + f"{frame.shape[:2]} 不符。" + ) + row, signature = _sample_frame_row( + frame_index, + float(times[frame_index]), + frame, + roi_polygon, + dark_polygons, + background_circle_mask, + prompt_cfg, + pan_cfg, + tic_cfg, + ) + duplicate = False + if tic_cfg.fitting.detect_duplicate_frames and previous_unique_signature is not None: + difference = float(np.mean(np.abs(signature - previous_unique_signature))) + duplicate = difference <= tic_cfg.fitting.duplicate_mean_absdiff_threshold + row["duplicate_frame"] = duplicate + rows.append(row) + if not duplicate: + previous_unique_signature = signature + if not rows: + raise ValueError("source_frames 不能为空。") + + unique_rows = [row for row in rows if not row["duplicate_frame"]] + detected_frames = sum(1 for row in unique_rows if row["valid_pan_px"] > 0) + pan_detected_fraction = detected_frames / len(unique_rows) if unique_rows else 0.0 + reference_valid = bool(unique_rows) and ( + pan_detected_fraction >= pan_cfg.min_detected_frame_fraction + ) + if not reference_valid: + for row in rows: + row["roi_pan_raw"] = float("nan") + + baselines, fits, early_truncation_flag = _attach_baselines_and_fits(rows, tic_cfg) + + return TicResult( + rows=rows, + parameters=None, + baselines=baselines, + fits=fits, + early_truncation_flag=early_truncation_flag, + reference_valid=reference_valid, + pan_detected_fraction=pan_detected_fraction, + ) diff --git a/src/cadx/ceus/_tic_curves.py b/src/cadx/ceus/_tic_curves.py new file mode 100644 index 0000000..9656c5b --- /dev/null +++ b/src/cadx/ceus/_tic_curves.py @@ -0,0 +1,436 @@ +"""tic_curves.npz 的打包和解包:四条曲线连同基线、拟合、面积、有效像素数(格式同旧项目)。 + +曲线数值单独存一份,问「第 37 秒那条曲线是多少」不必重跑整段视频。 + +- 解包用 `np.load(..., allow_pickle=False)`,坏字节直接报错; +- 打包前校验各逐帧数组长度一致; +- 字符串存 0 维 str 数组,不写 object 数组; +- NaN 原样写入:它是「这一帧没采到」的编码,本模块不用 `np.nan_to_num`,填 0 会把 + 「没采到」变成「亮度是 0」而不报错。 + +照搬旧项目 `tic_curves.py`,去掉产品层。 +""" + +import io +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np + +from cadx.ceus._geometry import AnyArray +from cadx.ceus._tic import CURVE_NAMES, TicResult + +# 这个文件自己的布局版本。键集、dtype、形状任一处变了就要换它。 +CURVES_SCHEMA_VERSION = "3" + +# 逐帧整数列的键名(面积 / 有效像素数 / 暗块计数),行键与工件键同名。 +_COUNT_KEYS = ( + "roi_area_px", + "dark_area_px", + "pan_area_px", + "background_circle_area_px", + "valid_roi_all_px", + "valid_roi_perf_px", + "valid_pan_px", + "valid_background_circle_px", + "dark_component_count", +) + + +@dataclass(frozen=True) +class TicCurveSeries: + """一条曲线的原始样本、候选曲线、Lognormal 结果与拟合质量。 + + ``fitted`` 保留优化器得到但可能未过质量门的曲线,便于复核失败;``processed`` 只在 + 质量门通过时等于 ``fitted``,失败时整条为 NaN。这样候选或失败拟合不会冒充正式曲线。 + """ + + raw: AnyArray + candidate: AnyArray + fitted: AnyArray + processed: AnyArray + valid_sample_mask: AnyArray + duplicate_sample_mask: AnyArray + baseline_mean: float + baseline_sd: float + baseline_frames: tuple[int, int] + baseline_valid: bool + fit_success: bool + fit_failure_reason: str + curve_model: str + smoothing_method: str + baseline_source: str + fit_area: float + fit_mu: float + fit_sigma: float + fit_arrival_offset_s: float + fit_baseline: float + fit_r2: float + fit_nrmse: float + residual_noise_ratio: float + fit_interval_s: tuple[float, float] + multi_peak_flag: bool + anchor_times_s: tuple[float, float, float, float] + + +@dataclass(frozen=True) +class TicCurves: + """TIC 曲线工件的内存表示(npz pack/unpack 的双向载体)。 + + 属性: + frame_indices: ``(T,)`` int32,逐帧下标。``T`` 是真实帧数,**不含任何合成点**。 + time_video_s: ``(T,)`` float32,来自 MP4 presentation timestamp。 + injection_offset_s: 推注偏移(秒);取不到时为 ``float("nan")``,**不是 0** + ——填 0 等于断言「视频和推注同时开始」,而那是我们没有的信息。 + time_origin: ``"injection"`` 或 ``"video_start"``,说明绝对时间轴以谁为零点。 + fps: 视频帧率。 + curves_schema_version: 这个文件自己的布局版本。 + curves: 曲线名 → ``TicCurveSeries``,键集恒为 ``tic.CURVE_NAMES``。 + roi_area_px / dark_area_px / pan_area_px / background_circle_area_px: + ``(T,)`` int32,逐帧各掩膜栅格面积。 + valid_roi_all_px / valid_roi_perf_px / valid_pan_px / + valid_background_circle_px: ``(T,)`` int32,逐帧真正参与统计的像素数。 + dark_component_count: ``(T,)`` int32,逐帧暗块个数。 + reference_valid: 紫色轮廓检出率达标,``roi_pan`` 那条可用。 + early_truncation_flag: 有曲线没能凑出足够长的基线区间。 + + **``roi_perf`` 这个名字断言了「暗区 = 无灌注区」这个等号。** 「灌注」指血液连同 + 造影剂真正流进一块组织,没有灌注的地方(囊液、坏死、出血)不会被点亮;``_perf`` + 后缀在规范里指「去掉无灌注区之后剩下的可灌注组织」。而我们量到的是**暗区**—— + 病变轮廓内部灰度明显偏低的那一块,它不必然等于无灌注区,**伪影**(超声成像自身 + 产生的假象,比如前方有气体或钙化挡住声波、后方整片变暗的声影)也会让有灌注的组织 + 显得暗。**这个等号是工科方在 2026-08-12 会上提出的,临床方带着「肯定会有被伪影干扰」 + 这个保留意见暂时认下**(旧项目会议转录 475 与 477 行;旧 design/0030 决策七据此写成)。 + 2026-08-16 项目负责人拍板先按这个等号做、暂不为伪影做任何区分,将来它被推翻时,要回头 + 重算的正是所有带 ``_perf`` 后缀的量。 + + **三样东西这里不存**,各有各的理由: + + ``S(t) = raw − B`` 不存。``B`` 已经在文件里了,再存一遍就是同一个数放两份, + 迟早有一份被改、另一份没改。 + + ``t_injection = time_video_s + injection_offset_s`` 不存。同上,两个加数都在。 + + 无灌注比例不存。临床方原规范 §6 给了公式 ``(A_all − A_perf) / A_all``,但没给它的时间下标 + ——取哪个时点还挂在临床方名下。逐帧的分子分母(``roi_area_px`` 与 ``dark_area_px``) + 都在文件里,时点定下来之后是一次派生,不必重跑视频。 + """ + + frame_indices: AnyArray + time_video_s: AnyArray + injection_offset_s: float + time_origin: str + fps: float + config_version: str + algorithm_version: str + curves_schema_version: str + curves: dict[str, TicCurveSeries] + roi_area_px: AnyArray + dark_area_px: AnyArray + pan_area_px: AnyArray + background_circle_area_px: AnyArray + valid_roi_all_px: AnyArray + valid_roi_perf_px: AnyArray + valid_pan_px: AnyArray + valid_background_circle_px: AnyArray + dark_component_count: AnyArray + perf_mask_stable: AnyArray + reference_valid: bool + early_truncation_flag: bool + + +def _nan_if_none(value: float | None) -> float: + """缺失的时刻在 npz 里记 NaN(float 数组没有 null)。""" + return float("nan") if value is None else float(value) + + +def from_tic_result( + result: TicResult, + fps: float, + injection_offset_s: float | None, + config_version: str, +) -> TicCurves: + """把 ``TicResult`` 转成 ``TicCurves``,调用方不必知道行键名。 + + 参数: + result: ``tic.compute_tic_for_video`` 的产物。 + fps: 视频帧率(>0)。 + injection_offset_s: 推注偏移(秒);源资料里没有就传 ``None``。 + config_version: 生成本工件的科研配置版本。 + + 返回: + ``TicCurves``。 + + 异常: + ValueError: ``result.rows`` 为空,或 ``fps`` 非正。 + + ``time_origin`` 由 ``injection_offset_s`` 派生,不单独作为入参:两者只有一种自洽的 + 搭配(有偏移才谈得上以推注为零点),拆成两个参数只是多一个能填错的地方。 + """ + if float(fps) <= 0.0: + raise ValueError(f"fps 必须为正,得到 {fps}。") + rows = result.rows + if not rows: + raise ValueError("TicResult.rows 不能为空。") + if not config_version: + raise ValueError("config_version 不能为空。") + algorithm_versions = {result.fits[name].algorithm_version for name in CURVE_NAMES} + if len(algorithm_versions) != 1: + raise ValueError(f"四条曲线的 algorithm_version 不一致:{algorithm_versions}") + + curves: dict[str, TicCurveSeries] = {} + for name in CURVE_NAMES: + baseline = result.baselines[name] + fit = result.fits[name] + parameters = fit.parameters + curves[name] = TicCurveSeries( + raw=np.asarray([row[f"{name}_raw"] for row in rows], dtype=np.float32), + candidate=np.asarray([row[f"{name}_candidate"] for row in rows], dtype=np.float32), + fitted=np.asarray([row[f"{name}_fitted"] for row in rows], dtype=np.float32), + processed=np.asarray([row[f"{name}_processed"] for row in rows], dtype=np.float32), + valid_sample_mask=fit.valid_sample_mask.astype(bool), + duplicate_sample_mask=fit.duplicate_sample_mask.astype(bool), + baseline_mean=float("nan") if baseline.mean is None else baseline.mean, + baseline_sd=float("nan") if baseline.sd is None else baseline.sd, + baseline_frames=(baseline.frame_start, baseline.frame_end), + baseline_valid=baseline.valid, + fit_success=fit.success, + fit_failure_reason=fit.failure_reason, + curve_model=fit.curve_model, + smoothing_method=fit.smoothing_method, + baseline_source=fit.baseline_source, + fit_area=float("nan") if parameters is None else parameters.area, + fit_mu=float("nan") if parameters is None else parameters.mu, + fit_sigma=float("nan") if parameters is None else parameters.sigma, + fit_arrival_offset_s=( + float("nan") if parameters is None else parameters.arrival_offset_s + ), + fit_baseline=float("nan") if parameters is None else parameters.baseline, + fit_r2=fit.r2, + fit_nrmse=fit.nrmse, + residual_noise_ratio=fit.residual_noise_ratio, + fit_interval_s=(fit.fit_start_s, fit.fit_end_s), + multi_peak_flag=fit.multi_peak_flag, + anchor_times_s=( + _nan_if_none(fit.d1_time_s), + _nan_if_none(fit.d2_time_s), + _nan_if_none(fit.d3_time_s), + _nan_if_none(fit.d4_time_s), + ), + ) + + counts = {key: np.asarray([row[key] for row in rows], dtype=np.int32) for key in _COUNT_KEYS} + frame_indices = np.asarray([row["frame_index"] for row in rows], dtype=np.int32) + return TicCurves( + frame_indices=frame_indices, + time_video_s=np.asarray([row["time_sec"] for row in rows], dtype=np.float32), + injection_offset_s=( + float("nan") if injection_offset_s is None else float(injection_offset_s) + ), + time_origin="video_start" if injection_offset_s is None else "injection", + fps=float(fps), + config_version=config_version, + algorithm_version=algorithm_versions.pop(), + curves_schema_version=CURVES_SCHEMA_VERSION, + curves=curves, + perf_mask_stable=np.asarray([row["perf_mask_stable"] for row in rows], dtype=bool), + reference_valid=result.reference_valid, + early_truncation_flag=result.early_truncation_flag, + **counts, + ) + + +def _validate_lengths(curves: TicCurves) -> int: + """校验逐帧列长度一致,返回帧数 T;任何不一致直接 raise。""" + if curves.frame_indices.ndim != 1: + raise ValueError(f"frame_indices 必须一维,得到 {curves.frame_indices.shape}。") + num_frames = int(curves.frame_indices.shape[0]) + curve_names = set(curves.curves) + expected_names = set(CURVE_NAMES) + if curve_names != expected_names: + raise ValueError(f"曲线键集必须为 {sorted(expected_names)},得到 {sorted(curve_names)}。") + if curves.curves_schema_version != CURVES_SCHEMA_VERSION: + raise ValueError( + f"只支持 curves schema {CURVES_SCHEMA_VERSION},得到 {curves.curves_schema_version}。" + ) + if not curves.config_version or not curves.algorithm_version: + raise ValueError("config_version 与 algorithm_version 不能为空。") + if num_frames == 0 or np.any(np.diff(curves.frame_indices) <= 0): + raise ValueError("frame_indices 必须非空且严格递增。") + if not np.isfinite(curves.time_video_s).all() or np.any(np.diff(curves.time_video_s) <= 0.0): + raise ValueError("time_video_s 必须有限且严格递增。") + + named: list[tuple[str, AnyArray]] = [("time_video_s", curves.time_video_s)] + named += [(key, getattr(curves, key)) for key in _COUNT_KEYS] + named.append(("perf_mask_stable", curves.perf_mask_stable)) + for name in CURVE_NAMES: + series = curves.curves[name] + named.append((f"{name}_raw", series.raw)) + named.append((f"{name}_candidate", series.candidate)) + named.append((f"{name}_fitted", series.fitted)) + named.append((f"{name}_processed", series.processed)) + named.append((f"{name}_valid_sample_mask", series.valid_sample_mask)) + named.append((f"{name}_duplicate_sample_mask", series.duplicate_sample_mask)) + for key, array in named: + if array.shape != (num_frames,): + raise ValueError(f"{key} 必须形如 ({num_frames},),得到 {array.shape}。") + + return num_frames + + +def pack_tic_curves(curves: TicCurves) -> bytes: + """把 ``TicCurves`` 校验并打包为压缩 npz 字节(产物工件)。 + + 参数: + curves: 待打包的曲线载体。 + + 返回: + 压缩 npz 的原始字节。 + + 异常: + ValueError: 逐帧列长度不一致,或曲线名缺失。 + + 关键实现: + 先 ``_validate_lengths`` 钉死长度(不一致即 raise,不静默对齐);字符串键存为 + 0 维 str 数组,标量存 ``(1,)`` 数组,布尔存 ``int8``,全程不写入任何 object + 数组(禁 pickle)。NaN 原样写入 float32——它是「这一帧没采到」的编码, + 任何形式的填零都会把这个事实抹掉。 + """ + _validate_lengths(curves) + + payload: dict[str, AnyArray] = { + "frame_indices": curves.frame_indices.astype(np.int32), + "time_video_s": curves.time_video_s.astype(np.float32), + "injection_offset_s": np.asarray([curves.injection_offset_s], dtype=np.float32), + "time_origin": np.array(curves.time_origin), + "fps": np.asarray([curves.fps], dtype=np.float32), + "config_version": np.array(curves.config_version), + "algorithm_version": np.array(curves.algorithm_version), + "curves_schema_version": np.array(curves.curves_schema_version), + "reference_valid": np.asarray([int(curves.reference_valid)], dtype=np.int8), + "early_truncation_flag": np.asarray([int(curves.early_truncation_flag)], dtype=np.int8), + "perf_mask_stable": curves.perf_mask_stable.astype(np.int8), + } + for key in _COUNT_KEYS: + payload[key] = np.asarray(getattr(curves, key), dtype=np.int32) + for name in CURVE_NAMES: + series = curves.curves[name] + payload[f"{name}_raw"] = series.raw.astype(np.float32) + payload[f"{name}_candidate"] = series.candidate.astype(np.float32) + payload[f"{name}_fitted"] = series.fitted.astype(np.float32) + payload[f"{name}_processed"] = series.processed.astype(np.float32) + payload[f"{name}_valid_sample_mask"] = series.valid_sample_mask.astype(np.int8) + payload[f"{name}_duplicate_sample_mask"] = series.duplicate_sample_mask.astype(np.int8) + payload[f"{name}_baseline_mean"] = np.asarray([series.baseline_mean], dtype=np.float32) + payload[f"{name}_baseline_sd"] = np.asarray([series.baseline_sd], dtype=np.float32) + payload[f"{name}_baseline_frames"] = np.asarray(series.baseline_frames, dtype=np.int32) + payload[f"{name}_baseline_valid"] = np.asarray([int(series.baseline_valid)], dtype=np.int8) + payload[f"{name}_fit_success"] = np.asarray([int(series.fit_success)], dtype=np.int8) + payload[f"{name}_fit_failure_reason"] = np.array(series.fit_failure_reason) + payload[f"{name}_curve_model"] = np.array(series.curve_model) + payload[f"{name}_smoothing_method"] = np.array(series.smoothing_method) + payload[f"{name}_baseline_source"] = np.array(series.baseline_source) + payload[f"{name}_fit_parameters"] = np.asarray( + [ + series.fit_area, + series.fit_mu, + series.fit_sigma, + series.fit_arrival_offset_s, + series.fit_baseline, + ], + dtype=np.float64, + ) + payload[f"{name}_fit_quality"] = np.asarray( + [series.fit_r2, series.fit_nrmse, series.residual_noise_ratio], + dtype=np.float64, + ) + payload[f"{name}_fit_interval_s"] = np.asarray(series.fit_interval_s, dtype=np.float64) + payload[f"{name}_multi_peak_flag"] = np.asarray( + [int(series.multi_peak_flag)], dtype=np.int8 + ) + payload[f"{name}_anchor_times_s"] = np.asarray(series.anchor_times_s, dtype=np.float64) + + buffer = io.BytesIO() + # 存根把 **payload 也当成可能填进 allow_pickle 等关键字参数,这里只有数组键。 + cast(Any, np.savez_compressed)(buffer, **payload) + return buffer.getvalue() + + +def unpack_tic_curves(raw: bytes) -> TicCurves: + """把 npz 字节解包还原为 ``TicCurves``(禁 pickle,坏字节 raise)。 + + 参数: + raw: ``pack_tic_curves`` 产出的 npz 原始字节。 + + 返回: + 还原后的 ``TicCurves``,dtype 与 pack 时一致。 + + 异常: + ValueError / OSError / KeyError: 非法 npz、含 object pickle 成员、或缺键。 + + 关键实现: + ``np.load(..., allow_pickle=False)`` 硬禁 pickle——任何含 object pickle 的恶意 + 字节都会在访问该成员时抛错而非被反序列化执行。NaN 原样取回,不做任何替换。 + """ + with np.load(io.BytesIO(raw), allow_pickle=False) as archive: + schema_version = str(archive["curves_schema_version"].item()) + if schema_version != CURVES_SCHEMA_VERSION: + raise ValueError( + f"只支持 curves schema {CURVES_SCHEMA_VERSION},得到 {schema_version}。" + ) + curves: dict[str, TicCurveSeries] = {} + for name in CURVE_NAMES: + frames = archive[f"{name}_baseline_frames"].astype(np.int32) + fit_parameters = archive[f"{name}_fit_parameters"].astype(np.float64) + fit_quality = archive[f"{name}_fit_quality"].astype(np.float64) + fit_interval = archive[f"{name}_fit_interval_s"].astype(np.float64) + anchor_times = archive[f"{name}_anchor_times_s"].astype(np.float64) + curves[name] = TicCurveSeries( + raw=archive[f"{name}_raw"].astype(np.float32), + candidate=archive[f"{name}_candidate"].astype(np.float32), + fitted=archive[f"{name}_fitted"].astype(np.float32), + processed=archive[f"{name}_processed"].astype(np.float32), + valid_sample_mask=archive[f"{name}_valid_sample_mask"].astype(bool), + duplicate_sample_mask=archive[f"{name}_duplicate_sample_mask"].astype(bool), + baseline_mean=float(archive[f"{name}_baseline_mean"][0]), + baseline_sd=float(archive[f"{name}_baseline_sd"][0]), + baseline_frames=(int(frames[0]), int(frames[1])), + baseline_valid=bool(archive[f"{name}_baseline_valid"][0]), + fit_success=bool(archive[f"{name}_fit_success"][0]), + fit_failure_reason=str(archive[f"{name}_fit_failure_reason"].item()), + curve_model=str(archive[f"{name}_curve_model"].item()), + smoothing_method=str(archive[f"{name}_smoothing_method"].item()), + baseline_source=str(archive[f"{name}_baseline_source"].item()), + fit_area=float(fit_parameters[0]), + fit_mu=float(fit_parameters[1]), + fit_sigma=float(fit_parameters[2]), + fit_arrival_offset_s=float(fit_parameters[3]), + fit_baseline=float(fit_parameters[4]), + fit_r2=float(fit_quality[0]), + fit_nrmse=float(fit_quality[1]), + residual_noise_ratio=float(fit_quality[2]), + fit_interval_s=(float(fit_interval[0]), float(fit_interval[1])), + multi_peak_flag=bool(archive[f"{name}_multi_peak_flag"][0]), + anchor_times_s=( + float(anchor_times[0]), + float(anchor_times[1]), + float(anchor_times[2]), + float(anchor_times[3]), + ), + ) + counts = {key: archive[key].astype(np.int32) for key in _COUNT_KEYS} + return TicCurves( + frame_indices=archive["frame_indices"].astype(np.int32), + time_video_s=archive["time_video_s"].astype(np.float32), + injection_offset_s=float(archive["injection_offset_s"][0]), + time_origin=str(archive["time_origin"].item()), + fps=float(archive["fps"][0]), + config_version=str(archive["config_version"].item()), + algorithm_version=str(archive["algorithm_version"].item()), + curves_schema_version=schema_version, + curves=curves, + perf_mask_stable=archive["perf_mask_stable"].astype(bool), + reference_valid=bool(archive["reference_valid"][0]), + early_truncation_flag=bool(archive["early_truncation_flag"][0]), + **counts, + ) diff --git a/src/cadx/ceus/_tic_fitting.py b/src/cadx/ceus/_tic_fitting.py new file mode 100644 index 0000000..8e23df5 --- /dev/null +++ b/src/cadx/ceus/_tic_fitting.py @@ -0,0 +1,928 @@ +"""第 7 步:TIC 的约束 Lognormal 拟合,以及拟合曲线上的连续时刻 D1–D4。 + +正式模型直接拟合清洗后的原始灰度样本。二阶 Savitzky-Golay 平滑只用来找候选峰、圈出第一遍 +团注(造影剂第一次流过)的时间段和给优化器初值;质量门不通过时,不把候选曲线顶替成正式的 +`processed` 曲线(`processed` 整条为 NaN),免得一条没拟合好的曲线冒充正式结果。 + +照搬旧项目 `tic_fitting.py`,去掉产品层;旧 `_empty_result` 与 `fit_lognormal_curve` 的参数 +默认值改成调用处显式传入。 +""" + +import itertools +import math +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np +from scipy.optimize import ( # pyright: ignore[reportMissingTypeStubs] + brentq, # pyright: ignore[reportUnknownVariableType] + least_squares, # pyright: ignore[reportUnknownVariableType] +) +from scipy.signal import ( # pyright: ignore[reportMissingTypeStubs] + find_peaks, # pyright: ignore[reportUnknownVariableType] + savgol_filter, # pyright: ignore[reportUnknownVariableType] +) + +from cadx.ceus._config import AnchorFramesConfig, TicFittingConfig +from cadx.ceus._geometry import AnyArray + + +@dataclass(frozen=True) +class LognormalParameters: + """Lognormal 团注曲线的五个参数:面积、对数均值、对数标准差、到达偏移(秒)、基线。""" + + area: float + mu: float + sigma: float + arrival_offset_s: float + baseline: float + + +@dataclass(frozen=True) +class LognormalFitResult: + """一条曲线的拟合结果。 + + `candidate` 是候选平滑曲线(只作检测用);`fitted` 是优化器给出的曲线(可能没过质量门); + `processed` 只在 `success` 时等于 `fitted`,否则整条 NaN。`d1_time_s`–`d4_time_s` 是拟合 + 曲线上的到达、洗入拐点、峰值、洗出拐点时刻(视频时间,秒),失败时为 None。 + """ + + success: bool + failure_reason: str + algorithm_version: str + curve_model: str + smoothing_method: str + baseline_source: str + candidate: AnyArray + fitted: AnyArray + processed: AnyArray + valid_sample_mask: AnyArray + duplicate_sample_mask: AnyArray + parameters: LognormalParameters | None + r2: float + nrmse: float + residual_noise_ratio: float + fit_start_s: float + fit_end_s: float + multi_peak_flag: bool + d1_time_s: float | None + d2_time_s: float | None + d3_time_s: float | None + d4_time_s: float | None + + +def evaluate_lognormal(time_s: AnyArray | float, parameters: LognormalParameters) -> AnyArray: + """带固定基线与到达偏移的 Lognormal 团注曲线在给定时刻的值。""" + times = np.asarray(time_s, dtype=np.float64) + values = np.full(times.shape, parameters.baseline, dtype=np.float64) + shifted = times - parameters.arrival_offset_s + positive = shifted > 0.0 + if not positive.any(): + return values + x_values = shifted[positive] + exponent = -((np.log(x_values) - parameters.mu) ** 2 / (2.0 * parameters.sigma**2)) + values[positive] += ( + parameters.area + / (x_values * parameters.sigma * math.sqrt(2.0 * math.pi)) + * np.exp(exponent) + ) + return values + + +def _empty_result( + size: int, + cfg: TicFittingConfig, + reason: str, + *, + candidate: AnyArray | None, + fitted: AnyArray | None, + valid_mask: AnyArray | None, + duplicate_mask: AnyArray | None, + parameters: LognormalParameters | None, + r2: float, + nrmse: float, + residual_noise_ratio: float, + fit_start_s: float, + fit_end_s: float, + multi_peak_flag: bool, + baseline_source: str, +) -> LognormalFitResult: + """拟合被拒时的结果:`success=False`、`processed` 全 NaN、四个时刻为 None。""" + nan_curve = np.full(size, np.nan, dtype=np.float64) + return LognormalFitResult( + success=False, + failure_reason=reason, + algorithm_version=cfg.algorithm_version, + curve_model="lognormal", + smoothing_method="", + baseline_source=baseline_source, + candidate=nan_curve.copy() if candidate is None else candidate, + fitted=nan_curve.copy() if fitted is None else fitted, + processed=nan_curve.copy(), + valid_sample_mask=(np.zeros(size, dtype=bool) if valid_mask is None else valid_mask), + duplicate_sample_mask=( + np.zeros(size, dtype=bool) if duplicate_mask is None else duplicate_mask + ), + parameters=parameters, + r2=r2, + nrmse=nrmse, + residual_noise_ratio=residual_noise_ratio, + fit_start_s=fit_start_s, + fit_end_s=fit_end_s, + multi_peak_flag=multi_peak_flag, + d1_time_s=None, + d2_time_s=None, + d3_time_s=None, + d4_time_s=None, + ) + + +def _finite_runs(mask: AnyArray) -> list[tuple[int, int]]: + """布尔序列里连续为真的段,每段 `[start, stop)`。""" + runs: list[tuple[int, int]] = [] + start: int | None = None + for index, value in enumerate(mask): + if value and start is None: + start = index + elif not value and start is not None: + runs.append((start, index)) + start = None + if start is not None: + runs.append((start, len(mask))) + return runs + + +def _odd_window(sample_count: int, desired: int, polynomial_order: int) -> int: + """Savitzky-Golay 窗口:奇数,不超过样本数,不小于多项式阶数 + 2。""" + maximum = sample_count if sample_count % 2 == 1 else sample_count - 1 + minimum = polynomial_order + 2 + if minimum % 2 == 0: + minimum += 1 + window = desired if desired % 2 == 1 else desired + 1 + return min(maximum, max(minimum, window)) + + +def _savgol(segment: AnyArray, window: int, polyorder: int) -> AnyArray: + """二阶 Savitzky-Golay 平滑(`mode="interp"`)。""" + return cast( + AnyArray, + savgol_filter(segment, window_length=window, polyorder=polyorder, mode="interp"), + ) + + +def _candidate_curve( + times: AnyArray, + values: AnyArray, + valid_mask: AnyArray, + cfg: TicFittingConfig, +) -> tuple[AnyArray, AnyArray, AnyArray, float]: + """把有效样本插值到等间隔网格,短窗与长窗两次平滑按 1:3 混合,得到候选曲线。 + + 相邻有效样本间隔超过 `max_candidate_gap_s` 的地方不插值(记 NaN),分段各自平滑。 + 返回(采样时刻上的候选值、网格、网格上的候选值、网格步长)。 + """ + valid_times = times[valid_mask] + intervals = np.diff(valid_times) + positive_intervals = intervals[intervals > 0.0] + if positive_intervals.size == 0: + raise ValueError("有效时间样本不足以建立候选网格") + grid_step = float(np.median(positive_intervals)) + grid = np.arange(valid_times[0], valid_times[-1] + grid_step * 0.5, grid_step, dtype=np.float64) + interpolated = np.interp(grid, valid_times, values[valid_mask]) + + for left, right in itertools.pairwise(valid_times): + if right - left - grid_step > cfg.max_candidate_gap_s: + interpolated[(grid > left) & (grid < right)] = np.nan + + smoothed = np.full(grid.shape, np.nan, dtype=np.float64) + finite = np.isfinite(interpolated) + short_window = max(1, round(cfg.candidate_window_s / grid_step)) + long_window = max(1, round(cfg.candidate_long_window_s / grid_step)) + for start, stop in _finite_runs(finite): + segment = interpolated[start:stop] + if segment.size <= cfg.candidate_polyorder + 1: + continue + short = _odd_window(int(segment.size), short_window, cfg.candidate_polyorder) + long = _odd_window(int(segment.size), long_window, cfg.candidate_polyorder) + if short <= cfg.candidate_polyorder or long <= cfg.candidate_polyorder: + continue + short_values = _savgol(segment, short, cfg.candidate_polyorder) + long_values = _savgol(segment, long, cfg.candidate_polyorder) + smoothed[start:stop] = 0.25 * short_values + 0.75 * long_values + + at_samples = np.full(times.shape, np.nan, dtype=np.float64) + for start, stop in _finite_runs(np.isfinite(smoothed)): + in_run = (times >= grid[start]) & (times <= grid[stop - 1]) + at_samples[in_run] = np.interp(times[in_run], grid[start:stop], smoothed[start:stop]) + return at_samples, grid, smoothed, grid_step + + +def _candidate_peaks( + grid: AnyArray, + candidate: AnyArray, + baseline: float, + baseline_noise: float, + baseline_end_s: float, + grid_step: float, + cfg: TicFittingConfig, + anchor_cfg: AnchorFramesConfig, +) -> list[int]: + """基线段之后的候选峰(网格下标):高度与突出度都须过「峰高比例」与「基线抖动倍数」的高者。""" + enhancement = candidate - baseline + eligible = np.isfinite(enhancement) & (grid >= baseline_end_s) + if not eligible.any(): + return [] + amplitude = float(np.nanmax(enhancement[eligible])) + if not math.isfinite(amplitude) or amplitude <= 0.0: + return [] + minimum_height = max( + cfg.minimum_relative_peak_height * amplitude, + anchor_cfg.peak_prominence_baseline_sd_multiplier * baseline_noise, + ) + distance = max(1, math.ceil(cfg.minimum_peak_distance_s / grid_step)) + peaks: list[int] = [] + for start, stop in _finite_runs(eligible): + segment = enhancement[start:stop] + if segment.size < 3: + continue + found = cast( + tuple[AnyArray, object], + find_peaks( + segment, height=minimum_height, prominence=minimum_height, distance=distance + ), + ) + local = found[0] + peaks.extend(int(value) + start for value in local.tolist()) + return sorted(peaks) + + +def _estimate_baseline( + values: AnyArray, + valid_mask: AnyArray, + cfg: TicFittingConfig, +) -> tuple[float, float]: + """视频开头没有稳定基线时,用低分位数估一个有界的基线和噪声。""" + finite = values[valid_mask] + baseline = float(np.percentile(finite, cfg.estimated_baseline_percentile)) + low_limit = float(np.percentile(finite, min(cfg.estimated_baseline_percentile * 2.0, 45.0))) + low_values = finite[finite <= low_limit] + center = float(np.median(low_values)) if low_values.size else baseline + noise = 1.4826 * float(np.median(np.abs(low_values - center))) + if not math.isfinite(noise) or noise <= 0.0: + differences = np.diff(finite) + difference_center = float(np.median(differences)) if differences.size else 0.0 + noise = 1.4826 * float(np.median(np.abs(differences - difference_center))) + amplitude = max(float(np.percentile(finite, 95.0) - baseline), 1e-3) + return baseline, max(noise, amplitude * 0.01, 1e-3) + + +def _candidate_detection_noise( + grid: AnyArray, + candidate: AnyArray, + baseline_end_s: float, + baseline_noise: float, +) -> float: + """检测用的噪声:基线段候选曲线一阶差分的稳健离散度,不超过基线噪声。""" + baseline_values = candidate[np.isfinite(candidate) & (grid <= baseline_end_s)] + differences = np.diff(baseline_values) + if differences.size < 2: + return baseline_noise + center = float(np.median(differences)) + difference_noise = 1.4826 * float(np.median(np.abs(differences - center))) + return min(baseline_noise, max(difference_noise / math.sqrt(2.0), 1e-3)) + + +def _select_primary_peak( + peaks: list[int], + grid: AnyArray, + candidate: AnyArray, + baseline: float, +) -> int | None: + """主峰:按「升降完整度 × 半高宽」打分,够强(≥ 最强者 1/4)的里取最早的一个。""" + if not peaks: + return None + finite_runs = _finite_runs(np.isfinite(candidate)) + scored: list[tuple[float, float, int]] = [] + for peak in peaks: + containing_run = next( + ((start, stop) for start, stop in finite_runs if start < peak < stop - 1), + None, + ) + if containing_run is None: + continue + first, stop = containing_run + last = stop - 1 + peak_height = float(candidate[peak] - baseline) + left = candidate[first : peak + 1] + right = candidate[peak : last + 1] + if peak_height <= 0.0: + continue + rise = float(candidate[peak] - np.min(left)) + decline = float(candidate[peak] - np.min(right)) + completeness = min(rise, decline) + half_prominence = float(candidate[peak] - 0.5 * completeness) + width_left = peak + while width_left > first and candidate[width_left - 1] >= half_prominence: + width_left -= 1 + width_right = peak + while width_right < last and candidate[width_right + 1] >= half_prominence: + width_right += 1 + width = float(grid[width_right] - grid[width_left]) + if width <= 0.0: + continue + scored.append((completeness * max(width, 1e-6), peak_height, peak)) + if not scored: + return None + complete = [item for item in scored if item[0] > 0.0 and item[1] > 0.0] + if complete: + strongest = max(item[0] for item in complete) + credible = [item for item in complete if item[0] >= strongest * 0.25] + if credible: + return min(credible, key=lambda item: item[2])[2] + return max(scored, key=lambda item: (item[0], item[1], -item[2]))[2] + + +def _persistent_arrival( + grid: AnyArray, + enhancement: AnyArray, + peak_index: int, + threshold: float, + persistence_s: float, +) -> float | None: + """主峰之前第一次越过门槛并持续 `persistence_s` 秒的时刻;没有返回 None。""" + above = enhancement >= threshold + run_start: int | None = None + for index in range(peak_index + 1): + if not np.isfinite(enhancement[index]) or not above[index]: + run_start = None + continue + if run_start is None: + run_start = index + if grid[index] - grid[run_start] >= persistence_s: + return float(grid[run_start]) + return None + + +def _first_bolus_end( + grid: AnyArray, + candidate: AnyArray, + baseline: float, + peaks: list[int], + primary_peak: int, + cfg: TicFittingConfig, +) -> tuple[float, bool]: + """第一遍团注到哪里结束:只把相隔足够远、谷值足够深的后峰视为第二遍团注。""" + first_peak = primary_peak + first_height = float(candidate[first_peak] - baseline) + for second_peak in (peak for peak in peaks if peak > first_peak): + if grid[second_peak] - grid[first_peak] < cfg.multi_peak_minimum_separation_s: + continue + second_height = float(candidate[second_peak] - baseline) + if second_height < cfg.multi_peak_minimum_relative_height * first_height: + continue + valley_slice = candidate[first_peak : second_peak + 1] + if not np.isfinite(valley_slice).all() or valley_slice.size < 3: + continue + valley_offset = int(np.argmin(valley_slice)) + valley_height = max(float(valley_slice[valley_offset] - baseline), 0.0) + reference_height = max(min(first_height, second_height), float(np.finfo(float).eps)) + if valley_height / reference_height <= cfg.multi_peak_maximum_valley_fraction: + return float(grid[first_peak + valley_offset]), True + return float(grid[-1]), False + + +def _unpack_optimizer_parameters(packed: AnyArray) -> LognormalParameters: + """优化器的参数化(对数峰高、峰时、对数 σ、到达偏移、基线)→ Lognormal 五参数。""" + peak_height = float(math.exp(float(packed[0]))) + peak_time_s = float(packed[1]) + sigma = float(math.exp(float(packed[2]))) + arrival_offset_s = float(packed[3]) + baseline = float(packed[4]) + mode_x = peak_time_s - arrival_offset_s + mu = math.log(mode_x) + sigma**2 + area = peak_height * mode_x * sigma * math.sqrt(2.0 * math.pi) * math.exp(0.5 * sigma**2) + return LognormalParameters( + area=area, + mu=mu, + sigma=sigma, + arrival_offset_s=arrival_offset_s, + baseline=baseline, + ) + + +def _initial_guesses( + peak_time_s: float, + peak_height: float, + t0_lower: float, + t0_upper: float, + baseline: float, + sigma_min: float, + sigma_max: float, +) -> list[AnyArray]: + """多组初值:到达偏移取区间两端与中点,σ 取四档,峰时取候选峰 ±0.25 秒。""" + guesses: list[AnyArray] = [] + t0_values = np.unique( + np.clip( + np.asarray([t0_lower, (t0_lower + t0_upper) / 2.0, t0_upper], dtype=np.float64), + t0_lower, + t0_upper, + ) + ) + peak_time_values = np.asarray( + [peak_time_s - 0.25, peak_time_s, peak_time_s + 0.25], dtype=np.float64 + ) + sigma_values = np.clip( + np.asarray([0.25, 0.5, 0.9, 1.3], dtype=np.float64), + sigma_min, + sigma_max, + ) + for t0_value in t0_values.tolist(): + for sigma in np.unique(sigma_values).tolist(): + for peak_time_value in peak_time_values.tolist(): + if peak_time_value <= t0_value: + continue + guesses.append( + np.asarray( + [ + math.log(max(peak_height, 1e-8)), + peak_time_value, + math.log(float(sigma)), + t0_value, + baseline, + ], + dtype=np.float64, + ) + ) + return guesses + + +def _optimizer_bounds( + values: AnyArray, + baseline: float, + baseline_noise: float, + peak_height: float, + t0_lower: float, + t0_upper: float, + peak_lower: float, + peak_upper: float, + baseline_observed: bool, + cfg: TicFittingConfig, +) -> tuple[AnyArray, AnyArray, float]: + """优化器参数的上下界和基线惩罚的尺度;观测到基线时基线只许在其附近小幅浮动。""" + amplitude = max(float(np.max(values) - baseline), peak_height, 1e-3) + height_upper = max(amplitude * 10.0, 1.0) + baseline_span = max(baseline_noise * 3.0, amplitude * 0.05, 1e-3) + if baseline_observed: + baseline_lower = baseline - baseline_span + baseline_upper = baseline + baseline_span + baseline_scale = baseline_span + else: + baseline_lower = max(0.0, float(np.percentile(values, 1.0)) - amplitude * 0.25) + baseline_upper = min(float(np.percentile(values, 45.0)), baseline + amplitude * 0.35) + baseline_upper = max(baseline_upper, baseline_lower + 1e-3) + baseline_scale = max(amplitude * 0.2, baseline_noise, 1e-3) + lower = np.asarray( + [ + math.log(1e-8), + peak_lower, + math.log(cfg.sigma_min), + t0_lower, + baseline_lower, + ], + dtype=np.float64, + ) + upper = np.asarray( + [ + math.log(height_upper), + peak_upper, + math.log(cfg.sigma_max), + t0_upper, + baseline_upper, + ], + dtype=np.float64, + ) + return lower, upper, baseline_scale + + +def _fit_parameters( + times: AnyArray, + values: AnyArray, + baseline: float, + baseline_noise: float, + peak_time_s: float, + peak_height: float, + t0_lower: float, + t0_upper: float, + peak_lower: float, + peak_upper: float, + baseline_observed: bool, + cfg: TicFittingConfig, +) -> LognormalParameters | None: + """从每组初值各跑一次有界稳健最小二乘,取平方误差最小的那组;全部失败返回 None。""" + lower, upper, baseline_scale = _optimizer_bounds( + values, + baseline, + baseline_noise, + peak_height, + t0_lower, + t0_upper, + peak_lower, + peak_upper, + baseline_observed, + cfg, + ) + amplitude = max(float(np.max(values) - baseline), peak_height, 1e-3) + residual_scale = max(baseline_noise, amplitude * 0.05, 1e-3) + + def residuals(packed: AnyArray) -> AnyArray: + if packed[1] <= packed[3]: + return np.full(values.size + 1, 1e6, dtype=np.float64) + parameters = _unpack_optimizer_parameters(packed) + data = (evaluate_lognormal(times, parameters) - values) / residual_scale + penalty = np.asarray([(parameters.baseline - baseline) / baseline_scale]) + return np.concatenate((data, penalty)) + + best: tuple[float, LognormalParameters] | None = None + for guess in _initial_guesses( + peak_time_s, + peak_height, + t0_lower, + t0_upper, + baseline, + cfg.sigma_min, + cfg.sigma_max, + ): + clipped = np.clip(guess, lower + 1e-9, upper - 1e-9) + result = cast( + Any, + least_squares( + residuals, + clipped, + bounds=(lower, upper), + loss=cfg.robust_loss, + f_scale=1.0, + max_nfev=cfg.max_nfev, + ), + ) + solution = np.asarray(cast(AnyArray, result.x), dtype=np.float64) + if not bool(result.success) or not np.isfinite(solution).all(): + continue + if solution[1] <= solution[3]: + continue + parameters = _unpack_optimizer_parameters(solution) + squared_error = float(np.sum((evaluate_lognormal(times, parameters) - values) ** 2)) + if best is None or squared_error < best[0]: + best = (squared_error, parameters) + return None if best is None else best[1] + + +def _parameters_on_boundary( + parameters: LognormalParameters, + lower: AnyArray, + upper: AnyArray, + fraction: float, +) -> bool: + """拟合参数(换回优化器参数化后)是否贴着上下界(区间宽度的 `fraction` 以内)。""" + peak_time_s = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2) + peak_height = float( + evaluate_lognormal(np.asarray([peak_time_s]), parameters)[0] - parameters.baseline + ) + packed = np.asarray( + [ + math.log(max(peak_height, 1e-8)), + peak_time_s, + math.log(parameters.sigma), + parameters.arrival_offset_s, + parameters.baseline, + ], + dtype=np.float64, + ) + margin = (upper - lower) * fraction + return bool(np.any(packed <= lower + margin) or np.any(packed >= upper - margin)) + + +def _quality_metrics( + observed: AnyArray, + fitted: AnyArray, + baseline_noise: float, +) -> tuple[float, float, float]: + """拟合优度:R²、按观测幅度归一的 RMSE、RMSE 与基线噪声之比。""" + residual = observed - fitted + residual_sum = float(np.sum(residual**2)) + centered_sum = float(np.sum((observed - float(np.mean(observed))) ** 2)) + r2 = float("nan") if centered_sum <= 0.0 else 1.0 - residual_sum / centered_sum + rmse = math.sqrt(residual_sum / max(int(observed.size), 1)) + amplitude = max(float(np.max(observed) - np.min(observed)), 1e-6) + nrmse = rmse / amplitude + residual_noise_ratio = rmse / max(baseline_noise, 1e-6) + return r2, nrmse, residual_noise_ratio + + +def evaluate_lognormal_derivative(time_s: float, parameters: LognormalParameters) -> float: + """Lognormal 曲线在某一时刻的一阶导数(到达偏移之前为 0)。""" + shifted = time_s - parameters.arrival_offset_s + if shifted <= 0.0: + return 0.0 + value = float(evaluate_lognormal(np.asarray([time_s]), parameters)[0]) + pulse = value - parameters.baseline + return ( + pulse + * (parameters.mu - parameters.sigma**2 - math.log(shifted)) + / (parameters.sigma**2 * shifted) + ) + + +def _arrival_root( + parameters: LognormalParameters, threshold: float, left: float, right: float +) -> float: + """拟合曲线在 `[left, right]` 内升过「基线 + 门槛」的时刻(Brent 求根)。""" + + def above_threshold(value: float) -> float: + return float( + evaluate_lognormal(np.asarray([value]), parameters)[0] - parameters.baseline - threshold + ) + + return float(cast(float, brentq(above_threshold, left, right))) + + +def _continuous_anchors( + parameters: LognormalParameters, + baseline_noise: float, + observed_end_s: float, + anchor_cfg: AnchorFramesConfig, + boundary_margin_s: float, +) -> tuple[float | None, float | None, float, float | None]: + """拟合曲线上的四个连续时刻:到达 D1、洗入拐点 D2、峰值 D3、洗出拐点 D4。 + + 到达门槛取「基线抖动 × 倍数」与「峰高 × 比例」的高者(`anchor_frames.arrival`)。D4 只在 + 峰后还有足够观测(`boundary_margin_s`)、且拐点落在观测范围内、导数为负时给出。 + """ + peak_time = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2) + peak_value = float(evaluate_lognormal(np.asarray([peak_time]), parameters)[0]) + peak_height = peak_value - parameters.baseline + threshold = max( + anchor_cfg.arrival.baseline_sd_multiplier * baseline_noise, + anchor_cfg.arrival.relative_peak_threshold * peak_height, + ) + + left = parameters.arrival_offset_s + 1e-9 + arrival: float | None = None + if threshold > 0.0 and peak_height >= threshold: + arrival = _arrival_root(parameters, threshold, left, peak_time) + + sigma_squared = parameters.sigma**2 + extrema_discriminant = math.sqrt(sigma_squared**2 + 4.0 * sigma_squared) + wash_in_factor = (sigma_squared + extrema_discriminant) / 2.0 + wash_out_factor = (sigma_squared - extrema_discriminant) / 2.0 + wash_in_candidate = parameters.arrival_offset_s + math.exp( + parameters.mu - sigma_squared - wash_in_factor + ) + wash_in_start = arrival if arrival is not None else left + d2_time = max(wash_in_start, wash_in_candidate) + + d4_time: float | None = None + if observed_end_s - peak_time >= boundary_margin_s: + candidate_d4 = parameters.arrival_offset_s + math.exp( + parameters.mu - sigma_squared - wash_out_factor + ) + if ( + candidate_d4 < observed_end_s - boundary_margin_s + and evaluate_lognormal_derivative(candidate_d4, parameters) < 0.0 + ): + d4_time = candidate_d4 + return arrival, d2_time, peak_time, d4_time + + +def fit_lognormal_curve( + time_s: AnyArray, + raw_values: AnyArray, + *, + baseline: float | None, + baseline_noise: float | None, + baseline_end_index: int, + duplicate_mask: AnyArray, + sample_mask: AnyArray | None, + cfg: TicFittingConfig, + anchor_cfg: AnchorFramesConfig, +) -> LognormalFitResult: + """拟合一条原始灰度 TIC,并给出连续的 D1–D4 时刻。 + + `baseline`/`baseline_noise` 为 None 或 `baseline_end_index <= 0` 表示视频开头没有观测到 + 稳定基线,改用低分位数估计(`baseline_source="estimated"`)。`sample_mask` 为 None 表示 + 全部样本可用。任何一道门不过都返回 `success=False`,原因写在 `failure_reason`。 + """ + times = np.asarray(time_s, dtype=np.float64) + values = np.asarray(raw_values, dtype=np.float64) + duplicates = np.asarray(duplicate_mask, dtype=bool) + if times.ndim != 1 or values.shape != times.shape or duplicates.shape != times.shape: + raise ValueError("time_s、raw_values 与 duplicate_mask 必须是等长一维数组") + if times.size == 0 or not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0): + raise ValueError("time_s 必须是非空、有限且严格递增的一维数组") + eligibility = ( + np.ones(times.shape, dtype=bool) + if sample_mask is None + else np.asarray(sample_mask, dtype=bool) + ) + if eligibility.shape != times.shape: + raise ValueError("sample_mask 必须与 time_s 等长") + valid_mask = np.isfinite(values) & ~duplicates & eligibility + + candidate_at_samples: AnyArray | None = None + fitted: AnyArray | None = None + parameters: LognormalParameters | None = None + r2 = nrmse = residual_noise_ratio = float("nan") + fit_start_s = fit_end_s = float("nan") + multi_peak = False + baseline_source = "unavailable" + + def reject(reason: str) -> LognormalFitResult: + return _empty_result( + len(times), + cfg, + reason, + candidate=candidate_at_samples, + fitted=fitted, + valid_mask=valid_mask, + duplicate_mask=duplicates, + parameters=parameters, + r2=r2, + nrmse=nrmse, + residual_noise_ratio=residual_noise_ratio, + fit_start_s=fit_start_s, + fit_end_s=fit_end_s, + multi_peak_flag=multi_peak, + baseline_source=baseline_source, + ) + + if int(valid_mask.sum()) < cfg.minimum_valid_samples: + return reject("insufficient_valid_samples") + + # 有效样本时刻全部相同时(理论上严格递增已排除)建不出候选网格,记为拟合失败。 + try: + candidate_at_samples, grid, candidate_grid, grid_step = _candidate_curve( + times, values, valid_mask, cfg + ) + except ValueError: + return reject("candidate_grid_unavailable") + + baseline_observed = ( + baseline is not None and baseline_noise is not None and baseline_end_index > 0 + ) + if baseline is not None and baseline_noise is not None and baseline_end_index > 0: + fit_baseline = float(baseline) + fit_baseline_noise = max(float(baseline_noise), 1e-3) + baseline_end_s = float(times[min(baseline_end_index - 1, len(times) - 1)]) + baseline_source = "observed" + else: + fit_baseline, fit_baseline_noise = _estimate_baseline(values, valid_mask, cfg) + baseline_end_s = float(times[valid_mask][0]) + baseline_source = "estimated" + detection_noise = _candidate_detection_noise( + grid, candidate_grid, baseline_end_s, fit_baseline_noise + ) + peaks = _candidate_peaks( + grid, + candidate_grid, + fit_baseline, + detection_noise, + baseline_end_s, + grid_step, + cfg, + anchor_cfg, + ) + first_peak = _select_primary_peak(peaks, grid, candidate_grid, fit_baseline) + if first_peak is None: + return reject("credible_peak_not_found") + + first_peak_height = float(candidate_grid[first_peak] - fit_baseline) + arrival_threshold = max( + anchor_cfg.arrival.baseline_sd_multiplier * detection_noise, + anchor_cfg.arrival.relative_peak_threshold * first_peak_height, + ) + if ( + _persistent_arrival( + grid, + candidate_grid - fit_baseline, + first_peak, + arrival_threshold, + anchor_cfg.arrival.persistence_s, + ) + is None + ): + return reject("candidate_arrival_not_found") + + fit_end_s, multi_peak = _first_bolus_end( + grid, candidate_grid, fit_baseline, peaks, first_peak, cfg + ) + + fit_mask = valid_mask & (times <= fit_end_s) + fit_times = times[fit_mask] + fit_values = values[fit_mask] + if fit_times.size < cfg.minimum_valid_samples: + return reject("insufficient_bolus_samples") + fit_start_s = float(fit_times[0]) + long_gaps = np.diff(fit_times) + pre_peak = fit_times[:-1] < float(grid[first_peak]) + if np.any(long_gaps[pre_peak] - grid_step > cfg.max_candidate_gap_s): + return reject("long_gap_in_wash_in") + + observed_start_s = fit_start_s + peak_lower = max( + observed_start_s + cfg.boundary_margin_s, + float(grid[first_peak]) - cfg.candidate_long_window_s, + ) + peak_upper = min( + fit_end_s - cfg.boundary_margin_s, + float(grid[first_peak]) + cfg.candidate_long_window_s, + ) + t0_lower = ( + observed_start_s + if baseline_observed + else observed_start_s - cfg.estimated_baseline_lookback_s + ) + t0_upper = peak_lower - max(grid_step * 0.25, 1e-4) + if t0_upper <= t0_lower or peak_upper <= peak_lower: + return reject("arrival_bounds_invalid") + + parameters = _fit_parameters( + fit_times, + fit_values, + fit_baseline, + fit_baseline_noise, + float(grid[first_peak]), + first_peak_height, + t0_lower, + t0_upper, + peak_lower, + peak_upper, + baseline_observed, + cfg, + ) + if parameters is None: + return reject("optimizer_failed") + + fitted = evaluate_lognormal(times, parameters) + fit_predictions = evaluate_lognormal(fit_times, parameters) + r2, nrmse, residual_noise_ratio = _quality_metrics( + fit_values, fit_predictions, fit_baseline_noise + ) + peak_time_s = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2) + lower, upper, _ = _optimizer_bounds( + fit_values, + fit_baseline, + fit_baseline_noise, + first_peak_height, + t0_lower, + t0_upper, + peak_lower, + peak_upper, + baseline_observed, + cfg, + ) + if _parameters_on_boundary(parameters, lower, upper, cfg.parameter_boundary_fraction): + return reject("parameter_on_boundary") + if ( + not observed_start_s + cfg.boundary_margin_s + < peak_time_s + < fit_end_s - cfg.boundary_margin_s + ): + return reject("model_peak_on_boundary") + residual_supported = ( + math.isfinite(residual_noise_ratio) + and residual_noise_ratio <= cfg.maximum_residual_noise_ratio + ) + variance_supported = math.isfinite(r2) and r2 >= cfg.minimum_r2 + if nrmse > cfg.maximum_nrmse or not (variance_supported or residual_supported): + return reject("fit_quality_failed") + + d1_time, d2_time, d3_time, d4_time = _continuous_anchors( + parameters, + fit_baseline_noise, + fit_end_s, + anchor_cfg, + cfg.boundary_margin_s, + ) + return LognormalFitResult( + success=True, + failure_reason="", + algorithm_version=cfg.algorithm_version, + curve_model="lognormal", + smoothing_method="", + baseline_source=baseline_source, + candidate=candidate_at_samples, + fitted=fitted, + processed=fitted.copy(), + valid_sample_mask=valid_mask, + duplicate_sample_mask=duplicates, + parameters=parameters, + r2=r2, + nrmse=nrmse, + residual_noise_ratio=residual_noise_ratio, + fit_start_s=observed_start_s, + fit_end_s=fit_end_s, + multi_peak_flag=multi_peak, + d1_time_s=d1_time, + d2_time_s=d2_time, + d3_time_s=d3_time, + d4_time_s=d4_time, + ) diff --git a/src/cadx/ceus/_tic_plot.py b/src/cadx/ceus/_tic_plot.py new file mode 100644 index 0000000..a0e8368 --- /dev/null +++ b/src/cadx/ceus/_tic_plot.py @@ -0,0 +1,181 @@ +# matplotlib 的类型存根把绘图方法的 **kwargs 标成 Unknown,这一个文件里关掉这条规则。 +# pyright: reportUnknownMemberType=false +"""tic.png:TIC 原始样本、过了质量门的 Lognormal 拟合曲线和关键帧时刻竖线。 + +紫圈曲线拟合没过质量门时仍画出拟合(虚线、标 QC rejected),便于复核;病灶曲线拟合没过就不画。 +照搬旧项目 `tic_plot.py`,去掉产品层。 +""" + +from collections.abc import Sequence +from dataclasses import dataclass +from io import BytesIO + +import numpy as np +from matplotlib.axes import Axes +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure + +from cadx.ceus._geometry import AnyArray +from cadx.ceus._tic import TicResult + +_FIGURE_SIZE = (16.0, 8.0) +_FIGURE_DPI = 100 +_CURVE_STYLES = { + "roi_all": ("roi_all", "#2563B8"), + "roi_perf": ("roi_perf (dark removed)", "#D46700"), + "roi_pan": ("roi_pan reference", "#7C3AED"), +} + + +@dataclass(frozen=True) +class TicPlotMarker: + """复核时间标记;颜色沿用 OpenCV 的 BGR 顺序。""" + + label: str + time_s: float + color: tuple[int, int, int] + + +def render_tic_png(tic: TicResult, *, markers: Sequence[TicPlotMarker]) -> bytes: + """把病灶与紫圈(正常胰腺对照)的 TIC 画成 PNG 字节;`markers` 是要画的时刻竖线。""" + times, raw_series, processed_series = _curve_arrays(tic) + figure = Figure( + figsize=_FIGURE_SIZE, + dpi=_FIGURE_DPI, + facecolor="white", + layout="constrained", + ) + canvas = FigureCanvasAgg(figure) + axes = figure.subplots() + _style_axes(axes) + + plotted_values: list[AnyArray] = [] + for name, (label, color) in _CURVE_STYLES.items(): + values = raw_series[name] + if np.isfinite(values).any(): + axes.plot( + times, + values, + color=color, + alpha=0.24, + linewidth=1.2, + marker=".", + markersize=2.2, + label=f"{label} raw", + ) + plotted_values.append(values) + + for name, (label, color) in _CURVE_STYLES.items(): + fit = tic.fits[name] + if fit.success: + values = processed_series[name] + fit_label = f"{label} Lognormal fit" + linestyle = "-" + elif name == "roi_pan": + values = np.asarray(fit.fitted, dtype=np.float64) + fit_label = f"{label} Lognormal fit [QC rejected]" + linestyle = "--" + else: + continue + if np.isfinite(values).any(): + axes.plot( + times, + values, + color=color, + linewidth=3.0, + linestyle=linestyle, + label=fit_label, + ) + plotted_values.append(values) + + _draw_markers(axes, markers, times) + _set_limits(axes, times, plotted_values) + if not plotted_values: + axes.text( + 0.5, + 0.5, + "No plottable TIC data", + ha="center", + va="center", + transform=axes.transAxes, + color="#555555", + fontsize=14, + ) + if axes.lines: + axes.legend(loc="lower center", ncol=3, framealpha=0.92, fontsize=11) + + with BytesIO() as output: + canvas.print_png(output) + return output.getvalue() + + +def _curve_arrays( + tic: TicResult, +) -> tuple[AnyArray, dict[str, AnyArray], dict[str, AnyArray]]: + times = np.asarray([row["time_sec"] for row in tic.rows], dtype=np.float64) + raw_series = { + name: np.asarray([row[f"{name}_raw"] for row in tic.rows], dtype=np.float64) + for name in _CURVE_STYLES + } + processed_series = { + name: np.asarray([row[f"{name}_processed"] for row in tic.rows], dtype=np.float64) + for name in _CURVE_STYLES + } + return times, raw_series, processed_series + + +def _style_axes(axes: Axes) -> None: + axes.set_title("TIC: Lognormal fit", fontsize=20, pad=12) + axes.set_xlabel("Time (s)", fontsize=15) + axes.set_ylabel("Grayscale intensity", fontsize=15) + axes.tick_params(axis="both", labelsize=12) + axes.grid(True, color="#B8B8B8", linewidth=0.8, alpha=0.32) + axes.set_axisbelow(True) + + +def _draw_markers( + axes: Axes, + markers: Sequence[TicPlotMarker], + times: AnyArray, +) -> None: + finite_times = times[np.isfinite(times)] + if not finite_times.size: + return + time_min = float(finite_times.min()) + time_max = float(finite_times.max()) + for marker in markers: + if not np.isfinite(marker.time_s) or not time_min <= marker.time_s <= time_max: + continue + axes.axvline( + marker.time_s, + color=tuple(channel / 255.0 for channel in reversed(marker.color)), + linestyle="--", + linewidth=1.5, + alpha=0.9, + label=marker.label, + ) + + +def _set_limits( + axes: Axes, + times: AnyArray, + series: Sequence[AnyArray], +) -> None: + finite_times = times[np.isfinite(times)] + if finite_times.size: + time_min = float(finite_times.min()) + time_max = float(finite_times.max()) + time_padding = max((time_max - time_min) * 0.025, 0.1) + axes.set_xlim(time_min - time_padding, time_max + time_padding) + + finite_values = [values[np.isfinite(values)] for values in series] + finite_values = [values for values in finite_values if values.size] + if not finite_values: + return + value_min = min(float(values.min()) for values in finite_values) + value_max = max(float(values.max()) for values in finite_values) + value_span = max(value_max - min(0.0, value_min), 1.0) + axes.set_ylim( + min(0.0, value_min) - 0.05 * value_span, + value_max + 0.08 * value_span, + ) diff --git a/src/cadx/ceus/_trajectory.py b/src/cadx/ceus/_trajectory.py new file mode 100644 index 0000000..e4b37e0 --- /dev/null +++ b/src/cadx/ceus/_trajectory.py @@ -0,0 +1,245 @@ +"""trajectory.npz 的打包和解包:逐帧 ROI 多边形、暗区、背景圆和几何元数据(格式同旧项目)。 + +键集对齐参考组件 `write_roi_trajectory_npz`,另有 `keyframe_indices`(提示帧帧号)、 +`dark_polygons`、`calibration_json` 三键。 + +- 解包用 `np.load(..., allow_pickle=False)`:变长和字符串数据一律存 JSON,不存 object 数组, + 读取时不会执行任意代码; +- 坏字节(不是合法 npz)解包直接报错; +- 打包前校验形状(多边形 `(N, 64, 2)`、`frame_indices` 长 N、`reference_mask` 二维等),不一致报错。 + +`source_effective_rect` / `target_effective_rect` 两键保留是为了格式同旧项目:旧 debug 路径不裁 +黑边,它们恒等于对应的视图矩形。照搬旧项目 `trajectory.py`,去掉产品层。 +""" + +import io +import json +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np + +from cadx.ceus._geometry import AnyArray, ViewRect + +# 重采样多边形的固定顶点数(与 geometry.resample_closed_contour 的下游约定一致)。 +POLYGON_NUM_POINTS = 64 + + +@dataclass +class Trajectory: + """逐帧 ROI 轨迹工件的内存表示(npz pack/unpack 的双向载体)。 + + 属性: + frame_indices: ``(N,)`` int32,逐帧索引(与多边形帧数一致)。 + fps: 视频帧率(浮点标量)。npz 存档内 fps 存为 float32(schema 保证,见 + ``pack_trajectory``);in-memory ``Trajectory.fps`` 有意规范化为 Python + float(与领域实体 ``CeusRoiTracking.fps`` 的 float 一致,便于消费)。 + target_polygons: ``(N, 64, 2)`` float32,目标视图逐帧 ROI 多边形。 + source_polygons: ``(N, 64, 2)`` float32,源视图逐帧 ROI 多边形。 + source_view_rect / target_view_rect: 源 / 目标视图整屏裁剪矩形。 + source_effective_rect / target_effective_rect: 源 / 目标有效成像区矩形。 + source_polygon_coordinate_space / target_polygon_coordinate_space: 多边形所处 + 坐标系名(如 ``source_view_local`` / ``target_effective_view_local``)。 + reference_mask: ``(H, W)`` uint8,参考区二值掩膜。 + reference_region: 参考区元信息字典(pack 时 JSON 序列化)。 + keyframe_indices: ``(K,)`` int32,被选作提示的关键帧索引(变长一维)。 + dark_polygons: ``list[list[AnyArray]]``,逐帧 0~N 个暗区多边形(变长嵌套, + pack 时整体 JSON 序列化为单个字符串,规避 npz 变长 / pickle)。 + calibration: 标定溯源字典(标定来源 / hash / 是否 CLI 临时覆盖 / 检测 split_x)。 + """ + + frame_indices: AnyArray + fps: float + target_polygons: AnyArray + source_polygons: AnyArray + source_view_rect: ViewRect + target_view_rect: ViewRect + source_effective_rect: ViewRect + target_effective_rect: ViewRect + source_polygon_coordinate_space: str + target_polygon_coordinate_space: str + reference_mask: AnyArray + reference_region: dict[str, Any] + keyframe_indices: AnyArray + dark_polygons: list[list[AnyArray]] + calibration: dict[str, Any] + + +def _rect_to_array(rect: ViewRect) -> AnyArray: + """把 ``ViewRect`` 编码为 ``(4,)`` int32 数组 ``[x1, y1, x2, y2]``(对齐参考 P:1676)。""" + return np.asarray([rect.x1, rect.y1, rect.x2, rect.y2], dtype=np.int32) + + +def _rect_from_array(values: AnyArray) -> ViewRect: + """把 ``(4,)`` 整型数组解码回 ``ViewRect``;不变量校验委托给 ViewRect 构造。""" + if values.shape != (4,): + raise ValueError(f"矩形数组必须形如 (4,),得到 {values.shape}。") + x1, y1, x2, y2 = (int(v) for v in values.tolist()) + return ViewRect(x1, y1, x2, y2) + + +def _serialize_dark_polygons(dark_polygons: list[list[AnyArray]]) -> str: + """把逐帧暗区多边形(变长嵌套 ndarray)序列化为 JSON 字符串。 + + 每个多边形 ``(M, 2)`` 转为嵌套 list;整体为 ``list[list[list[list[float]]]]`` + (帧 → 多边形 → 顶点 → 坐标)。走 JSON 而非 npz object 数组,从源头禁 pickle。 + """ + serializable: list[list[list[list[float]]]] = [] + for frame_polygons in dark_polygons: + frame_repr: list[list[list[float]]] = [] + for polygon in frame_polygons: + array = np.asarray(polygon, dtype=np.float32) + if array.ndim != 2 or array.shape[1] != 2: + raise ValueError(f"暗区多边形必须形如 (M, 2),得到 {array.shape}。") + frame_repr.append(array.tolist()) + serializable.append(frame_repr) + return json.dumps(serializable, ensure_ascii=False) + + +def _deserialize_dark_polygons(payload: str) -> list[list[AnyArray]]: + """把暗区多边形 JSON 字符串还原为 ``list[list[AnyArray]]``(float32)。 + + 严格按 JSON 结构还原,不接受任何 pickle / object 输入;结构非法直接 raise。 + """ + parsed: object = json.loads(payload) + if not isinstance(parsed, list): + raise ValueError("暗区多边形 JSON 顶层必须是逐帧列表。") + dark_polygons: list[list[AnyArray]] = [] + for frame_repr in cast(list[object], parsed): + if not isinstance(frame_repr, list): + raise ValueError("暗区多边形每帧必须是多边形列表。") + frame_polygons: list[AnyArray] = [] + for polygon_repr in cast(list[object], frame_repr): + array = np.asarray(polygon_repr, dtype=np.float32) + if array.ndim != 2 or array.shape[1] != 2: + raise ValueError(f"暗区多边形必须形如 (M, 2),得到 {array.shape}。") + frame_polygons.append(array) + dark_polygons.append(frame_polygons) + return dark_polygons + + +def _validate_geometry(trajectory: Trajectory) -> int: + """校验轨迹核心几何形状的一致性,返回帧数 N;任何不一致直接 raise。 + + 校验项:多边形 ``(N, 64, 2)`` 且源 / 目标帧数一致;``frame_indices`` 一维且长 N; + ``reference_mask`` 二维;``keyframe_indices`` 一维;``dark_polygons`` 帧数为 N。 + """ + target = trajectory.target_polygons + source = trajectory.source_polygons + if target.ndim != 3 or target.shape[1:] != (POLYGON_NUM_POINTS, 2): + raise ValueError( + f"target_polygons 必须形如 (N, {POLYGON_NUM_POINTS}, 2),得到 {target.shape}。" + ) + if source.shape != target.shape: + raise ValueError( + f"source_polygons {source.shape} 与 target_polygons {target.shape} 形状不一致。" + ) + num_frames = int(target.shape[0]) + if trajectory.frame_indices.ndim != 1 or trajectory.frame_indices.shape[0] != num_frames: + raise ValueError( + f"frame_indices 必须一维且长度 {num_frames},得到 {trajectory.frame_indices.shape}。" + ) + if trajectory.reference_mask.ndim != 2: + raise ValueError( + f"reference_mask 必须二维 (H, W),得到 {trajectory.reference_mask.shape}。" + ) + if trajectory.keyframe_indices.ndim != 1: + raise ValueError(f"keyframe_indices 必须一维,得到 {trajectory.keyframe_indices.shape}。") + if len(trajectory.dark_polygons) != num_frames: + raise ValueError( + f"dark_polygons 帧数 {len(trajectory.dark_polygons)} 与多边形帧数 {num_frames} 不一致。" + ) + return num_frames + + +def pack_trajectory(trajectory: Trajectory) -> bytes: + """把 ``Trajectory`` 校验并打包为压缩 npz 字节(产物工件)。 + + 参数: + trajectory: 待打包的逐帧轨迹载体。 + + 返回: + 压缩 npz 的原始字节(``np.savez_compressed`` 写入内存缓冲)。 + + 关键实现: + 先 ``_validate_geometry`` 钉死核心形状(不一致即 raise,不静默对齐); + 字符串键存为 0 维 str 数组,``fps`` 存 ``(1,)`` float32,变长 dark / 字典键 + 统一 JSON 序列化为字符串,全程不写入任何 object 数组(禁 pickle)。 + 键名对齐参考 ``write_roi_trajectory_npz`` 并新增 ``keyframe_indices`` / + ``dark_polygons`` / ``calibration_json``。 + """ + _validate_geometry(trajectory) + + buffer = io.BytesIO() + np.savez_compressed( + buffer, + frame_indices=trajectory.frame_indices.astype(np.int32), + fps=np.asarray([trajectory.fps], dtype=np.float32), + target_polygons=trajectory.target_polygons.astype(np.float32), + source_polygons=trajectory.source_polygons.astype(np.float32), + source_view_rect=_rect_to_array(trajectory.source_view_rect), + target_view_rect=_rect_to_array(trajectory.target_view_rect), + source_effective_rect=_rect_to_array(trajectory.source_effective_rect), + target_effective_rect=_rect_to_array(trajectory.target_effective_rect), + source_polygon_coordinate_space=np.array(trajectory.source_polygon_coordinate_space), + target_polygon_coordinate_space=np.array(trajectory.target_polygon_coordinate_space), + reference_mask=trajectory.reference_mask.astype(np.uint8), + reference_region_json=np.array(json.dumps(trajectory.reference_region, ensure_ascii=False)), + keyframe_indices=trajectory.keyframe_indices.astype(np.int32), + dark_polygons=np.array(_serialize_dark_polygons(trajectory.dark_polygons)), + calibration_json=np.array(json.dumps(trajectory.calibration, ensure_ascii=False)), + ) + return buffer.getvalue() + + +def unpack_trajectory(raw: bytes) -> Trajectory: + """把 npz 字节解包还原为 ``Trajectory``(禁 pickle,坏字节 raise)。 + + 参数: + raw: ``pack_trajectory`` 产出的 npz 原始字节。 + + 返回: + 还原后的 ``Trajectory``,dtype 与 pack 时一致。 + + 关键实现: + ``np.load(..., allow_pickle=False)`` 硬禁 pickle —— 任何含 object pickle 的 + 恶意字节都会在 load 阶段抛错而非被反序列化执行;非法 npz / 缺键同样直接 raise + (``ValueError`` / ``OSError`` / ``KeyError``),不静默返回空。字符串键经 0 维 + 数组 ``.item()`` 取回,变长 / 字典键经 JSON 反序列化还原。 + ``fps`` 存档内为 float32(schema 保证),此处有意还原为 Python float + (领域消费侧统一用 float,不改此行为)。 + """ + with np.load(io.BytesIO(raw), allow_pickle=False) as archive: + frame_indices = archive["frame_indices"].astype(np.int32) + fps = float(archive["fps"][0]) + target_polygons = archive["target_polygons"].astype(np.float32) + source_polygons = archive["source_polygons"].astype(np.float32) + source_view_rect = _rect_from_array(archive["source_view_rect"]) + target_view_rect = _rect_from_array(archive["target_view_rect"]) + source_effective_rect = _rect_from_array(archive["source_effective_rect"]) + target_effective_rect = _rect_from_array(archive["target_effective_rect"]) + source_polygon_coordinate_space = str(archive["source_polygon_coordinate_space"].item()) + target_polygon_coordinate_space = str(archive["target_polygon_coordinate_space"].item()) + reference_mask = archive["reference_mask"].astype(np.uint8) + reference_region = json.loads(str(archive["reference_region_json"].item())) + keyframe_indices = archive["keyframe_indices"].astype(np.int32) + dark_polygons = _deserialize_dark_polygons(str(archive["dark_polygons"].item())) + calibration = json.loads(str(archive["calibration_json"].item())) + + return Trajectory( + frame_indices=frame_indices, + fps=fps, + target_polygons=target_polygons, + source_polygons=source_polygons, + source_view_rect=source_view_rect, + target_view_rect=target_view_rect, + source_effective_rect=source_effective_rect, + target_effective_rect=target_effective_rect, + source_polygon_coordinate_space=source_polygon_coordinate_space, + target_polygon_coordinate_space=target_polygon_coordinate_space, + reference_mask=reference_mask, + reference_region=reference_region, + keyframe_indices=keyframe_indices, + dark_polygons=dark_polygons, + calibration=calibration, + ) diff --git a/src/cadx/segment/__init__.py b/src/cadx/segment/__init__.py new file mode 100644 index 0000000..ab62d35 --- /dev/null +++ b/src/cadx/segment/__init__.py @@ -0,0 +1,10 @@ +"""segment:用 MedSAM2 把提示帧的病灶掩码传播成逐帧掩码。 + +不认识分屏和绿框:输入只是要分割的那一半画面和那一半局部坐标上的提示掩码。`cadx` 里只有这里 +引用 `torch` 和 `sam2`。 +""" + +from cadx.segment._config import SegmentConfig +from cadx.segment._medsam2 import medsam2_commit, segment_video + +__all__ = ["SegmentConfig", "medsam2_commit", "segment_video"] diff --git a/src/cadx/segment/_config.py b/src/cadx/segment/_config.py new file mode 100644 index 0000000..9f483e1 --- /dev/null +++ b/src/cadx/segment/_config.py @@ -0,0 +1,44 @@ +"""分割设置:`config/ceus.yaml` 里 `segment` 段的字段定义和校验。 + +每个字段必填;多写或拼错的键报错;读入后不能修改(`AGENTS.md`「不设默认值」)。取值与出处写在 +`config/ceus.yaml` 的注释里。路径字段原样使用,相对路径由调用者(`scripts/ceus_quant.py`) +先换成仓库根目录下的绝对路径。 +""" + +from pathlib import Path +from typing import Annotated + +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field + + +def _reject_empty_path(value: object) -> object: + # Path("") 会变成 "."(当前目录),空串在转换前就拦下。 + if value == "": + raise ValueError("路径不能是空串") + return value + + +# YAML 里的路径是字符串;路径字段是唯一允许从字符串转换类型的地方。 +type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_empty_path)] + + +class SegmentConfig(BaseModel): + """MedSAM2 分割设置。 + + - `medsam2_dir`:MedSAM2 代码目录(含 `.git`,提交号从这里读;装进环境的 `sam2` 包必须来自 + 这个目录下的 `sam2/`); + - `checkpoint`:权重文件; + - `sam2_config`:模型配置的 Hydra 名,相对 `sam2` 包,如 `configs/sam2.1_hiera_t512.yaml`; + - `device`:推理设备,如 `cuda`、`cuda:0`、`cpu`; + - `offload_video_to_cpu`、`offload_state_to_cpu`:MedSAM2 `init_state` 的两个开关,把帧或推理 + 状态放在内存而不是显存,省显存、略慢,不改变结果。 + """ + + model_config = ConfigDict(strict=True, extra="forbid", frozen=True) + + medsam2_dir: _PathField + checkpoint: _PathField + sam2_config: str + device: str + offload_video_to_cpu: bool + offload_state_to_cpu: bool diff --git a/src/cadx/segment/_medsam2.py b/src/cadx/segment/_medsam2.py new file mode 100644 index 0000000..8214ce1 --- /dev/null +++ b/src/cadx/segment/_medsam2.py @@ -0,0 +1,166 @@ +"""MedSAM2 逐帧分割:把若干提示帧的病灶掩码传播到整段视频的每一帧。 + +改写自旧项目 `debugs/medsam2_local_track.py`(当时在 conda 子进程里跑),行为照旧:帧以 +`000000.jpg` 起的 jpg 序列(质量 95)写进一个目录交给 `init_state`;每个提示帧用 `add_new_mask` +加进同一个对象;从最早的提示帧向后传播到末帧,再从它向前传播到第 0 帧;logits > 0 为病灶。 +现在在同一进程里调用,jpg 临时目录由这里建、用完即删。 +""" + +import os +import shutil +import subprocess +import tempfile +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np +import numpy.typing as npt +import torch + +from cadx.segment._config import SegmentConfig + +type Frame = npt.NDArray[np.uint8] +type Mask = npt.NDArray[np.bool_] + +# 只跟踪一个病灶,对象编号照旧脚本取 1。 +_OBJECT_ID = 1 +_JPEG_QUALITY = 95 + + +def medsam2_commit(medsam2_dir: Path) -> str: + """读 `medsam2_dir/.git` 的 HEAD 提交号(完整 40 位)。 + + 用 `--git-dir` 指定仓库:只用 `-C` 时,目录里没有 `.git` 的话 git 会往上找到 CADx-4 自己的仓库。 + """ + git = shutil.which("git") + if git is None: + raise RuntimeError("找不到 git,无法读 MedSAM2 的提交号") + completed = subprocess.run( # noqa: S603 参数是写死的 git 子命令和配置里的目录 + [git, "--git-dir", str(medsam2_dir / ".git"), "rev-parse", "HEAD"], + check=True, + capture_output=True, + ) + return completed.stdout.decode().strip() + + +def _check_inputs( + frames: Sequence[Frame], prompt_masks: Mapping[int, Mask], cfg: SegmentConfig +) -> tuple[int, int]: + """校验输入并返回帧的 (高, 宽)。""" + for path in (cfg.medsam2_dir / "sam2" / cfg.sam2_config, cfg.checkpoint): + if not path.is_file(): + raise FileNotFoundError(f"MedSAM2 缺少文件:{path}") + if len(frames) == 0: + raise ValueError("没有要分割的帧") + height, width = frames[0].shape[:2] + for index, frame in enumerate(frames): + if frame.dtype != np.uint8 or frame.shape != (height, width, 3): + raise ValueError( + f"第 {index} 帧应为 ({height}, {width}, 3) 的 uint8 BGR 图像," + f"收到 {frame.shape} {frame.dtype}" + ) + if len(prompt_masks) == 0: + raise ValueError("至少需要一个提示帧") + for index, mask in prompt_masks.items(): + if not 0 <= index < len(frames): + raise ValueError(f"提示帧帧号 {index} 不在 [0, {len(frames)}) 内") + if mask.dtype != np.bool_ or mask.shape != (height, width): + raise ValueError( + f"提示帧 {index} 的掩码应为 ({height}, {width}) 的布尔数组," + f"收到 {mask.shape} {mask.dtype}" + ) + return height, width + + +def _check_device(device: str) -> None: + """加载权重前确认请求的设备可见,提前暴露显卡可见性问题。""" + if not device.startswith("cuda"): + return + if not torch.cuda.is_available(): + visible = os.environ.get("CUDA_VISIBLE_DEVICES", "") + raise RuntimeError( + f"请求用 CUDA 运行 MedSAM2,但当前进程看不到显卡。CUDA_VISIBLE_DEVICES={visible}" + ) + if ":" in device and int(device.split(":", 1)[1]) >= torch.cuda.device_count(): + raise RuntimeError(f"请求设备 {device},但只看得到 {torch.cuda.device_count()} 张显卡") + + +def _check_sam2_source(medsam2_dir: Path) -> None: + """装进环境的 sam2 必须就是 `medsam2_dir` 里那份代码,否则记下的提交号对不上实际代码。""" + import sam2 + + loaded = Path(sam2.__file__).resolve().parent + expected = (medsam2_dir / "sam2").resolve() + if loaded != expected: + raise RuntimeError(f"环境里的 sam2 来自 {loaded},与配置的 {expected} 不一致") + + +def _write_jpgs(frames: Sequence[Frame], frames_dir: Path) -> None: + for index, frame in enumerate(frames): + path = frames_dir / f"{index:06d}.jpg" + if not cv2.imwrite(str(path), frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_QUALITY]): + raise RuntimeError(f"写分割用的 jpg 失败:{path}") + + +def _to_mask(mask_logits: torch.Tensor) -> Mask: + return np.squeeze((mask_logits > 0.0).detach().cpu().numpy()).astype(np.bool_) + + +def _propagate(predictor: Any, state: Any, first_prompt: int) -> dict[int, Mask]: + """从最早的提示帧向后传播到末帧,再向前传播到第 0 帧。""" + propagated: dict[int, Mask] = {} + with torch.inference_mode(): + for frame_idx, obj_ids, mask_logits in predictor.propagate_in_video( + state, start_frame_idx=first_prompt + ): + propagated[int(frame_idx)] = _to_mask(mask_logits[list(obj_ids).index(_OBJECT_ID)]) + for frame_idx, obj_ids, mask_logits in predictor.propagate_in_video( + state, + start_frame_idx=first_prompt, + max_frame_num_to_track=first_prompt + 1, + reverse=True, + ): + propagated[int(frame_idx)] = _to_mask(mask_logits[list(obj_ids).index(_OBJECT_ID)]) + return propagated + + +def segment_video( + frames: Sequence[Frame], prompt_masks: Mapping[int, Mask], cfg: SegmentConfig +) -> Mask: + """把提示帧的病灶掩码传播到每一帧。 + + `frames` 是逐帧 BGR 图像(同一尺寸),`prompt_masks` 是提示帧帧号 → 与帧同尺寸的布尔掩码。 + 返回 `(帧数, 高, 宽)` 的布尔数组,与输入帧一一对应。 + """ + height, width = _check_inputs(frames, prompt_masks, cfg) + _check_device(cfg.device) + _check_sam2_source(cfg.medsam2_dir) + # sam2 没有类型标注,参数类型是 Unknown;返回的 predictor 按 Any 用。 + from sam2.build_sam import build_sam2_video_predictor # pyright: ignore[reportUnknownVariableType] + + predictor: Any = build_sam2_video_predictor( + config_file=cfg.sam2_config, ckpt_path=str(cfg.checkpoint), device=cfg.device + ) + with tempfile.TemporaryDirectory(prefix="cadx-medsam2-") as tmp: + frames_dir = Path(tmp) + _write_jpgs(frames, frames_dir) + state: Any = predictor.init_state( + str(frames_dir), + offload_video_to_cpu=cfg.offload_video_to_cpu, + offload_state_to_cpu=cfg.offload_state_to_cpu, + ) + for index in sorted(prompt_masks): + predictor.add_new_mask(state, frame_idx=index, obj_id=_OBJECT_ID, mask=prompt_masks[index]) + propagated = _propagate(predictor, state, min(prompt_masks)) + + missing = [index for index in range(len(frames)) if index not in propagated] + if missing: + raise RuntimeError(f"MedSAM2 传播后缺 {len(missing)} 帧的掩码,前几帧:{missing[:5]}") + stack = np.stack([propagated[index] for index in range(len(frames))], axis=0) + if stack.shape != (len(frames), height, width): + raise RuntimeError( + f"MedSAM2 输出掩码形状 {stack.shape},应为 {(len(frames), height, width)}" + ) + return stack diff --git a/tests/test_ceus_anchor_frames.py b/tests/test_ceus_anchor_frames.py new file mode 100644 index 0000000..fbd9463 --- /dev/null +++ b/tests/test_ceus_anchor_frames.py @@ -0,0 +1,392 @@ +"""连续 Lognormal 锚点时刻到真实视频帧的映射测试(照搬旧项目 test_anchor_frames.py)。 + +旧测试里 `excel_video` 模式(D1 取医生标注的到达时间、推注时间按「推注发生在视频第 X 秒」换算) +的几条已删,见任务书 02「四处要改的地方」第 3、4 条;新语义的手算测试在文件末尾。 +""" + +import numpy as np +import pytest + +from cadx.ceus import ( + ANCHOR_SLOTS, + AnchorFrameLocation, + CeusConfig, + LognormalFitResult, + locate_anchor_frames, +) +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) +_ANCHOR_CFG = _CONFIG.tic_analysis.anchor_frames + + +def _fit( + times: np.ndarray, + *, + success: bool = True, + d1: float | None = 5.0, + d2: float | None = 6.0, + d3: float | None = 8.0, + d4: float | None = 11.0, + duplicate_indices: tuple[int, ...] = (), + baseline_source: str = "observed", +) -> LognormalFitResult: + duplicates = np.zeros(times.size, dtype=bool) + duplicates[list(duplicate_indices)] = True + values = np.ones(times.size, dtype=np.float64) + return LognormalFitResult( + success=success, + failure_reason="" if success else "fit_quality_failed", + algorithm_version="lognormal-v2", + curve_model="lognormal", + smoothing_method="", + baseline_source=baseline_source, + candidate=values.copy(), + fitted=values.copy(), + processed=values.copy() if success else np.full(times.size, np.nan), + valid_sample_mask=np.ones(times.size, dtype=bool), + duplicate_sample_mask=duplicates, + parameters=None, + r2=0.9, + nrmse=0.1, + residual_noise_ratio=1.0, + fit_start_s=float(times[0]), + fit_end_s=float(times[-1]), + multi_peak_flag=False, + d1_time_s=d1, + d2_time_s=d2, + d3_time_s=d3, + d4_time_s=d4, + ) + + +def _locate( + *, + perf: LognormalFitResult | None = None, + offset: float | None = None, + lesion_fit_ready: bool = True, + baseline_end_index: int = 50, +) -> dict[str, AnchorFrameLocation]: + times = np.arange(0.0, 70.0, 0.1) + return locate_anchor_frames( + perf_fit=perf or _fit(times), + perf_baseline_end_index=baseline_end_index, + lesion_fit_ready=lesion_fit_ready, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=offset, + cfg=_ANCHOR_CFG, + ) + + +def test_model_times_map_to_expected_frames() -> None: + locations = _locate(offset=2.0) + + assert set(locations) == set(ANCHOR_SLOTS) + assert locations["B0"].frame_index == 47 + assert locations["D1"].frame_index == 50 + assert locations["D2"].frame_index == 60 + assert locations["D3"].frame_index == 80 + assert locations["D4"].frame_index == 110 + assert locations["D3"].time_s == pytest.approx(10.0) + assert locations["D3"].anchor_time_video_s == pytest.approx(8.0) + assert locations["D3"].frame_time_video_s == pytest.approx(8.0) + assert locations["D3"].time_error_s == pytest.approx(0.0) + assert locations["D3"].source_curve == "roi_perf" + assert locations["F1"].frame_index == 180 + assert locations["F2"].frame_index == 580 + + +def test_mapping_skips_duplicate_frame_and_chooses_earlier_tie() -> None: + times = np.arange(0.0, 20.0, 0.1) + perf = _fit(times, d1=5.05, duplicate_indices=(50,)) + locations = locate_anchor_frames( + perf_fit=perf, + perf_baseline_end_index=50, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + assert locations["D1"].frame_index == 51 + assert locations["D1"].time_s is None + + +def test_discrete_mapping_does_not_reverse_anchor_order() -> None: + times = np.arange(0.0, 20.0, 0.1) + perf = _fit(times, d1=5.0, d2=5.01, d3=8.0, d4=11.0) + locations = locate_anchor_frames( + perf_fit=perf, + perf_baseline_end_index=50, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + assert locations["D1"].frame_index == 50 + assert locations["D2"].frame_index == 51 + assert locations["D2"].status == "ok" + assert locations["D3"].frame_index == 80 + + +def test_failed_fit_does_not_fall_back_to_candidate_curve() -> None: + times = np.arange(0.0, 70.0, 0.1) + locations = _locate(perf=_fit(times, success=False), offset=2.0) + + for slot in ("D1", "D2", "D3", "D4"): + assert locations[slot].status == "anchor_undefined" + assert locations[slot].frame_index is None + + +def test_missing_washout_keeps_d4_undefined() -> None: + times = np.arange(0.0, 70.0, 0.1) + locations = _locate(perf=_fit(times, d4=None), offset=2.0) + assert locations["D1"].status == "ok" + assert locations["D4"].status == "not_covered" + + +def test_b0_uses_perf_arrival_without_pan_reference() -> None: + locations = _locate(offset=2.0) + assert locations["B0"].status == "ok" + assert locations["B0"].frame_index == 47 + assert locations["D1"].status == "ok" + + +def test_estimated_baseline_keeps_b0_undefined_but_maps_dynamic_anchors() -> None: + times = np.arange(0.0, 70.0, 0.1) + locations = _locate(perf=_fit(times, baseline_source="estimated"), offset=2.0) + + assert locations["B0"].status == "anchor_undefined" + assert locations["D1"].status == "ok" + assert locations["D4"].status == "ok" + + +def test_b0_cannot_map_after_arrival_or_outside_observed_baseline() -> None: + times = np.asarray([0.0, 5.1, 6.0, 8.0, 11.0]) + locations = locate_anchor_frames( + perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=11.0), + perf_baseline_end_index=1, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + assert locations["B0"].frame_index is None + assert locations["B0"].status == "no_frame_in_window" + + +def test_failed_second_lesion_curve_blocks_dynamic_screenshots() -> None: + locations = _locate(lesion_fit_ready=False, offset=2.0) + + for slot in ("B0", "D1", "D2", "D3", "D4"): + assert locations[slot].status == "anchor_undefined" + assert locations[slot].frame_index is None + + +def test_uncovered_d4_does_not_erase_available_earlier_slots() -> None: + times = np.arange(0.0, 10.1, 0.1) + locations = locate_anchor_frames( + perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=20.0), + perf_baseline_end_index=50, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + assert locations["D1"].status == "ok" + assert locations["D2"].status == "ok" + assert locations["D3"].status == "ok" + assert locations["D4"].status == "not_covered" + + +def test_pre_video_d1_does_not_attach_to_first_frame() -> None: + times = np.arange(0.0, 10.1, 0.1) + locations = locate_anchor_frames( + perf_fit=_fit(times, d1=-0.1, d2=1.0, d3=4.0, d4=8.0), + perf_baseline_end_index=-1, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + assert locations["D1"].frame_index is None + assert locations["D1"].status == "not_covered" + assert locations["D2"].status == "ok" + + +def test_d4_does_not_map_to_fit_end_frame() -> None: + times = np.arange(0.0, 11.0, 1.0) + perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.7) + locations = locate_anchor_frames( + perf_fit=perf, + perf_baseline_end_index=2, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + assert perf.fit_end_s == pytest.approx(10.0) + assert locations["D4"].frame_index is None + assert locations["D4"].status == "no_frame_in_window" + + +def test_d4_reselects_eligible_frame_before_fit_end() -> None: + times = np.arange(0.0, 11.0, 1.0) + perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.6) + locations = locate_anchor_frames( + perf_fit=perf, + perf_baseline_end_index=2, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG.model_copy(update={"d4_window_s": 1.0}), + ) + + assert locations["D4"].frame_index == 9 + assert locations["D4"].status == "ok" + + +def test_order_collision_has_distinct_status() -> None: + times = np.asarray([0.0, 2.0, 4.0]) + locations = locate_anchor_frames( + perf_fit=_fit(times, d1=2.0, d2=2.0, d3=None, d4=None), + perf_baseline_end_index=1, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + assert {locations["D1"].status, locations["D2"].status} == { + "ok", + "no_ordered_frame", + } + + +def test_missing_injection_offset_only_disables_fixed_time_frames() -> None: + locations = _locate(offset=None) + assert locations["D1"].status == "ok" + assert locations["D1"].frame_index == 50 + assert locations["D1"].time_s is None + assert locations["F1"].status == "not_covered" + assert locations["F2"].status == "not_covered" + + +def test_frame_indices_must_be_strictly_increasing() -> None: + times = np.arange(0.0, 20.0, 0.1) + indices = np.arange(times.size, dtype=np.int32) + indices[51] = indices[50] + with pytest.raises(ValueError, match="frame_indices 必须严格递增"): + locate_anchor_frames( + perf_fit=_fit(times), + perf_baseline_end_index=50, + lesion_fit_ready=True, + frame_indices=indices, + time_video_s=times, + injection_offset_s=None, + cfg=_ANCHOR_CFG, + ) + + +# ---------- 任务书 02 第 4 条:推注时间 X 是视频第 0 秒时已推注的秒数 ---------- +# 推注后第 T 秒在视频第 T − X 秒。时间轴每 0.1 秒一帧,帧号 = 视频时间 × 10。 + + +def test_bolus_x7_puts_f1_at_13s_and_f2_at_53s() -> None: + """X=7:F1(推注后 20 秒)在视频第 13 秒,F2(推注后 60 秒)在视频第 53 秒。""" + times = np.arange(0.0, 70.0, 0.1) + locations = _locate(perf=_fit(times), offset=7.0) + + assert locations["F1"].status == "ok" + assert locations["F1"].anchor_time_video_s == pytest.approx(13.0) + assert locations["F1"].frame_index == 130 + assert locations["F1"].frame_time_video_s == pytest.approx(13.0) + assert locations["F1"].time_s == pytest.approx(20.0) + assert locations["F1"].reason is None + assert locations["F2"].status == "ok" + assert locations["F2"].anchor_time_video_s == pytest.approx(53.0) + assert locations["F2"].frame_index == 530 + assert locations["F2"].time_s == pytest.approx(60.0) + + +def test_bolus_x25_leaves_f1_before_video_unlocated() -> None: + """X=25:F1 在视频第 −5 秒,落在视频之外记未定位、写原因,不拿第 0 帧补;F2 在第 35 秒照常。""" + times = np.arange(0.0, 70.0, 0.1) + locations = _locate(perf=_fit(times), offset=25.0) + + assert locations["F1"].status == "not_covered" + assert locations["F1"].frame_index is None + assert locations["F1"].anchor_time_video_s == pytest.approx(-5.0) + assert locations["F1"].reason is not None + assert "-5.00" in locations["F1"].reason + assert locations["F2"].status == "ok" + assert locations["F2"].frame_index == 350 + + +def test_bolus_time_past_video_end_is_unlocated() -> None: + """X=0、视频只有 50 秒:F2(视频第 60 秒)超过视频末尾,记未定位。""" + times = np.arange(0.0, 50.0, 0.1) + locations = locate_anchor_frames( + perf_fit=_fit(times), + perf_baseline_end_index=50, + lesion_fit_ready=True, + frame_indices=np.arange(times.size, dtype=np.int32), + time_video_s=times, + injection_offset_s=0.0, + cfg=_ANCHOR_CFG, + ) + + assert locations["F1"].frame_index == 200 + assert locations["F2"].status == "not_covered" + assert locations["F2"].frame_index is None + assert locations["F2"].anchor_time_video_s == pytest.approx(60.0) + + +@pytest.mark.parametrize("value", [float("nan"), -1.0]) +def test_invalid_bolus_time_fails_explicitly(value: float) -> None: + """推注时间不是有限非负秒数时直接报错,不当作「没有推注时间」。""" + with pytest.raises(ValueError, match="injection_offset_s"): + _locate(offset=value) + + +# ---------- 任务书 02 第 3 条:D1 只从拟合曲线算 ---------- + + +def test_d1_comes_from_fitted_arrival_time() -> None: + """D1 取拟合曲线上的到达时刻(这里 d1=5.0 秒 → 第 50 帧),来源记 roi_perf。""" + times = np.arange(0.0, 70.0, 0.1) + locations = _locate(perf=_fit(times, d1=5.0), offset=0.0) + + assert locations["D1"].frame_index == 50 + assert locations["D1"].anchor_time_video_s == pytest.approx(5.0) + assert locations["D1"].source_curve == "roi_perf" + + +def test_failed_fit_unlocates_b0_to_d4_with_reason_and_keeps_f1_f2() -> None: + """拟合没过质量门:B0–D4 未定位并写明原因,F1、F2 照常按推注时间取。""" + times = np.arange(0.0, 70.0, 0.1) + locations = _locate(perf=_fit(times, success=False), offset=7.0, lesion_fit_ready=False) + + for slot in ("B0", "D1", "D2", "D3", "D4"): + assert locations[slot].status == "anchor_undefined" + assert locations[slot].frame_index is None + reason = locations[slot].reason + assert reason is not None + assert "质量门" in reason + assert locations["F1"].frame_index == 130 + assert locations["F2"].frame_index == 530 diff --git a/tests/test_ceus_dark_region.py b/tests/test_ceus_dark_region.py new file mode 100644 index 0000000..30b21b5 --- /dev/null +++ b/tests/test_ceus_dark_region.py @@ -0,0 +1,308 @@ +"""暗区识别(dark_region)单元测试(照搬旧项目 test_ceus_dark_region.py)。 + +旧标题:B7 暗区识别(dark_region)单元测试。 + +合成"亮 ROI 内嵌暗斑"图像钉死六个 port 的真实行为,并核对评分公式的三个硬编码 +常量(64.0 / 4.0 / 2.0)与时间一致性 IoU。测试用真实 OpenCV 像素管线(非 mock), +覆盖:otsu 检出暗斑、auto_ensemble 择优、min_area/min_area_ratio 过滤碎片、 +exclude_prompt_color 剔绿、enabled=False 返回空且保留帧对齐、逐帧轮廓重采样 64 点。 +""" + +import numpy as np + +from cadx.ceus import ( + CeusConfig, + InteriorDarkRegionConfig, + apply_dark_morphology, + dark_candidate_mask, + extract_dark_region_masks, + extract_dark_region_polygons, + score_dark_candidate, + split_dark_components, +) +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) +# 剔绿框色用的 HSV 阈值:旧代码没传时回退的写死阈值与 prompt_source 相同,这里显式传入。 +_PROMPT = _CONFIG.prompt_source + + +def _dark_cfg(**overrides: object) -> InteriorDarkRegionConfig: + """ceus.yaml 的暗区段,按需改几项(代替旧的 `InteriorDarkRegionConfig(...)`)。""" + return _CONFIG.interior_dark_region.model_copy(update=overrides) + + +# ---------- 合成样本 ---------- + +_H, _W = 120, 160 +# 整幅 ROI 多边形:占据画面中央的大矩形。 +_ROI_POLYGON = np.array( + [[20.0, 20.0], [140.0, 20.0], [140.0, 100.0], [20.0, 100.0]], dtype=np.float32 +) +# ROI 内的暗斑位置(一个明确的低灰度方块)。 +_DARK_BOX = (slice(45, 75), slice(60, 100)) # (y, x),30×40=1200 px + + +def _bright_roi_with_dark_box() -> np.ndarray: + """构造一帧 BGR:全黑画布上铺亮 ROI(灰度 200),其内嵌暗斑(灰度 40)。""" + frame = np.zeros((_H, _W, 3), dtype=np.uint8) + # 亮 ROI 区 + frame[20:100, 20:140] = 200 + # 暗斑 + frame[_DARK_BOX] = 40 + return frame + + +def _roi_mask() -> np.ndarray: + from cadx.ceus import polygon_to_mask + + return polygon_to_mask(_ROI_POLYGON, _H, _W) + + +def _gray(frame: np.ndarray) -> np.ndarray: + import cv2 + + return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + +# ---------- dark_candidate_mask / split / morphology ---------- + + +def test_otsu_candidate_detects_dark_box() -> None: + """otsu 法应在 ROI 内检出暗斑(暗斑像素被标记,亮区不被标记)。""" + cfg = _dark_cfg() + frame = _bright_roi_with_dark_box() + gray = _gray(frame) + sample = _roi_mask() + + dark = dark_candidate_mask(gray, sample, "otsu", cfg) + + assert dark.dtype == bool + # 暗斑中心应被标记 + assert dark[60, 80] + # 亮区中心不应被标记 + assert not dark[30, 30] + # 暗斑覆盖率应显著(形态学后仍是大块) + assert dark.sum() > 500 + + +def test_split_components_filters_small_fragments() -> None: + """min_area / min_area_ratio 联合过滤:组件阈 = max(min_area, ROI面积×ratio)。""" + sample = _roi_mask() + roi_area = float(sample.sum()) + # 造一个 ~1200px 暗块 + 一个 ~16px 碎片 + dark = np.zeros((_H, _W), dtype=bool) + dark[45:75, 60:100] = True # 1200 px + dark[10:14, 10:14] = True # 16 px 碎片(且在 ROI 外,会被 roi_mask 裁掉) + dark[50:54, 30:34] = True # 16 px 碎片(ROI 内) + + cfg = _dark_cfg(min_area=300, min_area_ratio=0.015) + # 阈值 = max(300, roi_area*0.015) + threshold = max(300.0, roi_area * 0.015) + assert threshold >= 300.0 + + components = split_dark_components(dark, sample, cfg) + # 只剩大暗块 + assert len(components) == 1 + assert components[0].sum() >= 300 + + +def test_morphology_intersects_sample_mask() -> None: + """形态学结果须与 sample_mask 取交(不外溢 ROI)。""" + cfg = _dark_cfg(morphology_kernel=5) + sample = _roi_mask() + dark = np.zeros((_H, _W), dtype=bool) + dark[45:75, 60:100] = True + dark[0:5, 0:5] = True # ROI 外的块 + + out = apply_dark_morphology(dark, sample, cfg) + assert not out[2, 2] # ROI 外被裁掉 + assert (out & ~sample).sum() == 0 + + +# ---------- score_dark_candidate(三硬编码常量) ---------- + + +def test_score_contrast_constant_64() -> None: + """明暗对比项 = max(0, (median_bright - median_dark) / 64.0)。 + + 用无碎片惩罚 + 无时间项 + None preferred_range 的配置隔离对比项贡献。 + """ + cfg = _dark_cfg(fragment_penalty_weight=0.0, temporal_consistency_weight=0.0) + frame = _bright_roi_with_dark_box() + gray = _gray(frame) + sample = _roi_mask() + component = np.zeros((_H, _W), dtype=bool) + component[_DARK_BOX] = True + + score = score_dark_candidate(gray, sample, [component], None, cfg) + + union = component & sample + median_bright = float(np.median(gray[(~union) & sample])) + median_dark = float(np.median(gray[union & sample])) + contrast = max(0.0, (median_bright - median_dark) / 64.0) + area_ratio = float(union.sum()) / max(1.0, float(sample.sum())) + area_score = min(area_ratio, 1.0 - area_ratio) * 2.0 + expected = contrast + area_score + assert score == expected + + +def test_score_empty_components_returns_sentinel() -> None: + """空组件列表返回极小值哨兵(择优时永远不会被选)。""" + cfg = _dark_cfg() + gray = _gray(_bright_roi_with_dark_box()) + sample = _roi_mask() + assert score_dark_candidate(gray, sample, [], None, cfg) == -1e9 + + +def test_score_fragment_penalty_and_temporal_iou() -> None: + """碎片惩罚 = w×max(0, 块数−1);时间一致性 = temporal_weight × IoU(union, prev)。""" + cfg = _dark_cfg(fragment_penalty_weight=0.08, temporal_consistency_weight=0.35) + frame = _bright_roi_with_dark_box() + gray = _gray(frame) + sample = _roi_mask() + comp_a = np.zeros((_H, _W), dtype=bool) + comp_a[45:75, 60:80] = True + comp_b = np.zeros((_H, _W), dtype=bool) + comp_b[45:75, 80:100] = True + components = [comp_a, comp_b] + union = (comp_a | comp_b) & sample + prev = comp_a.copy() # 上一帧暗部 = comp_a + + score = score_dark_candidate(gray, sample, components, prev, cfg) + + from cadx.ceus import mask_iou + + median_bright = float(np.median(gray[(~union) & sample])) + median_dark = float(np.median(gray[union & sample])) + contrast = max(0.0, (median_bright - median_dark) / 64.0) + area_ratio = float(union.sum()) / max(1.0, float(sample.sum())) + area_score = min(area_ratio, 1.0 - area_ratio) * 2.0 + fragment_penalty = 0.08 * 1 # 2 块 → max(0, 2-1)=1 + temporal = mask_iou(union, prev) * 0.35 + expected = contrast + area_score + temporal - fragment_penalty + assert abs(score - expected) < 1e-9 + + +def test_score_preferred_range_slope_4() -> None: + """preferred_area_ratio_range 命中时 area_score=1,否则 max(0, 1 - dist×4.0)。""" + # 这个键旧 schema 里没有(旧测试用 object.__setattr__ 注入),现在是配置字段。 + cfg = _dark_cfg( + fragment_penalty_weight=0.0, + temporal_consistency_weight=0.0, + preferred_area_ratio_range=[0.001, 0.005], + ) + frame = _bright_roi_with_dark_box() + gray = _gray(frame) + sample = _roi_mask() + component = np.zeros((_H, _W), dtype=bool) + component[_DARK_BOX] = True # 1200/roi_area + + score = score_dark_candidate(gray, sample, [component], None, cfg) + + union = component & sample + area_ratio = float(union.sum()) / max(1.0, float(sample.sum())) + median_bright = float(np.median(gray[(~union) & sample])) + median_dark = float(np.median(gray[union & sample])) + contrast = max(0.0, (median_bright - median_dark) / 64.0) + low, high = 0.001, 0.005 + if low <= area_ratio <= high: + area_score = 1.0 + else: + nearest = low if area_ratio < low else high + area_score = max(0.0, 1.0 - abs(area_ratio - nearest) * 4.0) + assert abs(score - (contrast + area_score)) < 1e-9 + + +# ---------- extract_dark_region_masks ---------- + + +def test_auto_ensemble_selects_dark_box() -> None: + """auto_ensemble 跑 candidate_methods 择优,应选出覆盖暗斑的暗块。""" + cfg = _dark_cfg(threshold_method="auto_ensemble") + frame = _bright_roi_with_dark_box() + + masks, union = extract_dark_region_masks(frame, _ROI_POLYGON, cfg, None, _PROMPT) + + assert masks, "auto_ensemble 应检出暗块" + assert union is not None + # 暗斑中心被选中暗块覆盖 + assert union[60, 80] + # 不外溢 ROI + assert (union & ~_roi_mask()).sum() == 0 + + +def test_exclude_prompt_color_removes_green() -> None: + """exclude_prompt_color=True 时 ROI 内绿像素从分析中剔除(不计入暗 / 亮统计)。""" + frame = _bright_roi_with_dark_box() + # 在 ROI 内画一块纯绿(BGR),它在 HSV 绿区间内,灰度也偏低 + frame[80:95, 30:60] = (0, 255, 0) + + cfg_on = _dark_cfg(exclude_prompt_color=True) + _masks_on, union_on = extract_dark_region_masks(frame, _ROI_POLYGON, cfg_on, None, _PROMPT) + # 绿块不应进入选中暗块 + assert union_on is not None + assert not union_on[87, 45], "绿像素应被剔除,不计入暗块" + + +def test_disabled_returns_empty_polys_but_keeps_alignment() -> None: + """enabled=False:每帧返回空多边形列表,但帧数与输入对齐(§5 不变量)。""" + cfg = _dark_cfg(enabled=False) + frames = [_bright_roi_with_dark_box() for _ in range(3)] + polys = [_ROI_POLYGON for _ in range(3)] + + result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT) + assert len(result) == 3 + assert all(frame_polys == [] for frame_polys in result) + + +def test_polygons_resampled_to_64_points_and_aligned() -> None: + """逐帧暗块轮廓重采样到 contour_points=64 点;帧对齐保留(N 帧进 N 帧出)。""" + cfg = _dark_cfg(contour_points=64) + frames = [_bright_roi_with_dark_box() for _ in range(4)] + polys = [_ROI_POLYGON for _ in range(4)] + + result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT) + + assert len(result) == 4 + # 每帧至少一个暗块多边形(暗斑稳定) + for frame_polys in result: + assert frame_polys, "每帧应检出暗斑多边形" + for poly in frame_polys: + assert poly.shape == (64, 2) + assert poly.dtype == np.float32 + + +def _bright_roi_with_donut_dark() -> np.ndarray: + """构造一帧 BGR:亮 ROI(200)内嵌暗斑(40),暗斑中心再挖一个亮洞(200)→ 甜甜圈暗区。""" + frame = np.zeros((_H, _W, 3), dtype=np.uint8) + frame[20:100, 20:140] = 200 # 亮 ROI + frame[45:75, 60:100] = 40 # 暗斑(1200 px) + frame[55:65, 72:88] = 200 # 中心亮洞(10×16=160 px)→ 暗区成环 + return frame + + +def test_preserve_holes_keeps_hole_contour() -> None: + """preserve_holes=True:甜甜圈暗区的多边形列表须含外轮廓 + 洞轮廓(≥2 个)。 + + 回归 Codex 发现的 Critical——洞由 split_dark_components 挖回 mask, + _significant_contours 须沿 hierarchy 追加子轮廓(参考 P:685-692),否则洞被丢弃。 + """ + cfg = _dark_cfg(preserve_holes=True, contour_points=64) + frames = [_bright_roi_with_donut_dark()] + polys = [_ROI_POLYGON] + + result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT) + + assert len(result) == 1 + frame_polys = result[0] + # 外环 + 中心洞 → 至少两个多边形 + assert len(frame_polys) >= 2, f"应含外轮廓 + 洞,实得 {len(frame_polys)} 个" + # 每个多边形均重采样到 64 点 + assert all(p.shape == (64, 2) for p in frame_polys) + # 存在一个明显小于外环的多边形(洞)——按面积区分 + from cadx.ceus import polygon_area + + areas = sorted(polygon_area(p) for p in frame_polys) + assert areas[0] < areas[-1], "洞多边形面积应显著小于外轮廓" diff --git a/tests/test_ceus_overlay.py b/tests/test_ceus_overlay.py new file mode 100644 index 0000000..366e24d --- /dev/null +++ b/tests/test_ceus_overlay.py @@ -0,0 +1,358 @@ +"""overlay 的单元测试(照搬旧项目 test_ceus_overlay.py;画层配置与图层的默认值移到测试里)。 + +测试哲学(CLAUDE.md §4.6):用已知几何构造真实可断言的场景,验证 +- ``draw_polygon_overlay`` 在整帧坐标系画各层(左 ROI 黄 / 右 ROI 绿 / 暗区橙红 / + 背景圆青),输出帧尺寸不变、对应位置出现对应 BGR 颜色像素、fill 做 alpha 混合; +- ``render_overlay`` 返回非空 mp4 字节,cv2 读回后帧数 / 尺寸与输入一致; +- 临时文件 finally 清理(渲染前后 tempfile 目录无 ``.mp4`` 残留); +- 无效 fps 直接报错(不静默兜底,呼应 findings §2E 不走参考的 25.0 回退)。 + +不做逐像素全图比对——只在已知轮廓位置抽样断言颜色,符合任务要求。 +""" + +import tempfile +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np +import pytest + +from cadx.ceus import ( + OverlayConfig, + OverlayLayers, + ViewRect, + draw_polygon_overlay, + render_overlay, +) + +# 整帧尺寸:左右两半各 100 宽,便于把 source / target 多边形放进各自视图。 +_FRAME_HEIGHT = 120 +_FRAME_WIDTH = 200 +_LEFT_RECT = ViewRect(0, 0, 100, _FRAME_HEIGHT) +_RIGHT_RECT = ViewRect(100, 0, _FRAME_WIDTH, _FRAME_HEIGHT) + + +def _overlay_cfg(**overrides: bool) -> OverlayConfig: + """旧 `OverlayConfig` 的默认值(findings §3.10),按需改几项;新代码里这些字段没有默认值。""" + values = { + "draw_source_roi": False, + "draw_target_polygon": True, + "draw_dark_region": True, + "draw_source_dark_region": False, + "draw_reference_region": True, + "fill_polygon": False, + } + values.update(overrides) + return OverlayConfig(**values) + + +def _layers(**fields: Any) -> OverlayLayers: + """旧 `OverlayLayers` 的默认值(可选层为空),按需给几项;新代码里这些字段没有默认值。""" + values: dict[str, Any] = { + "dark_polygons": [], + "source_roi_polygon": None, + "source_rect": None, + "source_dark_polygons": [], + "reference_region": None, + } + values.update(fields) + return OverlayLayers(**values) + + +def _black_frame() -> np.ndarray: + """构造全黑 BGR 整帧,作为干净画布(背景与所有彩色层都可区分)。""" + return np.zeros((_FRAME_HEIGHT, _FRAME_WIDTH, 3), dtype=np.uint8) + + +def _square_polygon(x: int, y: int, side: int) -> np.ndarray: + """构造左上角 ``(x, y)``、边长 ``side`` 的局部坐标方形多边形 ``(4, 2)``。""" + return np.array( + [[x, y], [x + side, y], [x + side, y + side], [x, y + side]], + dtype=np.float32, + ) + + +def _has_color(frame: np.ndarray, bgr: tuple[int, int, int]) -> bool: + """整帧内是否存在与给定 BGR 完全相等的像素(用于断言某层已被画出)。""" + return bool(np.any(np.all(frame == np.array(bgr, dtype=np.uint8), axis=-1))) + + +def _count_mp4_in(directory: Path) -> int: + """统计目录下 ``.mp4`` 文件数量(用于断言临时产物已清理)。""" + return len(list(directory.glob("*.mp4"))) + + +def test_draw_keeps_frame_shape_and_does_not_mutate_input() -> None: + """绘制后输出帧尺寸 / dtype 不变,且不就地修改入参(返回副本)。""" + frame = _black_frame() + original = frame.copy() + layers = _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + ) + out = draw_polygon_overlay(frame, layers, _overlay_cfg()) + assert out.shape == frame.shape + assert out.dtype == frame.dtype + assert np.array_equal(frame, original) # 入参未被污染 + + +def test_target_polygon_drawn_green() -> None: + """右侧 target ROI 轮廓默认画绿 (0,255,0),且坐标抬到整帧右半区。""" + layers = _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + ) + out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) + assert _has_color(out, (0, 255, 0)) + # 绿色像素应落在右半区(x >= 100),证明做了 target_rect 偏移。 + green = np.all(out == np.array((0, 255, 0), dtype=np.uint8), axis=-1) + xs = np.where(green)[1] + assert xs.min() >= 100 + + +def test_source_roi_drawn_yellow_when_enabled() -> None: + """开启 draw_source_roi 时左侧 ROI 轮廓画黄 (0,255,255) 并落在左半区。""" + layers = _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + source_roi_polygon=_square_polygon(20, 20, 40), + source_rect=_LEFT_RECT, + ) + out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(draw_source_roi=True)) + assert _has_color(out, (0, 255, 255)) + yellow = np.all(out == np.array((0, 255, 255), dtype=np.uint8), axis=-1) + assert np.where(yellow)[1].max() < 100 + + +def test_source_roi_not_drawn_when_disabled() -> None: + """draw_source_roi 默认 False(findings §3.10),不应出现黄色像素。""" + layers = _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + source_roi_polygon=_square_polygon(20, 20, 40), + source_rect=_LEFT_RECT, + ) + out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) + assert not _has_color(out, (0, 255, 255)) + + +def test_dark_region_drawn_orange_red() -> None: + """暗区轮廓默认画橙红 (255,80,20),落在右半区(与 target 共 rect)。""" + layers = _layers( + target_polygon=_square_polygon(20, 20, 60), + target_rect=_RIGHT_RECT, + dark_polygons=[_square_polygon(30, 30, 20)], + ) + out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) + assert _has_color(out, (255, 80, 20)) + + +def test_reference_region_drawn_cyan_when_enabled() -> None: + """背景圆启用时左侧画青色小圆 (255,255,0),落在左半区。 + + **颜色不能是紫 (255,0,255)**:紫色是医生画的 ``ROI_pan`` 轮廓的颜色,两圈紫叠在 + 同一画面上人眼分不出哪个是哪个(design/0030 决策四)。 + """ + layers = _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + source_rect=_LEFT_RECT, + reference_region={"enabled": True, "center_xy": [40.0, 40.0], "radius_px": 12}, + ) + out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) + assert _has_color(out, (255, 255, 0)) + assert not _has_color(out, (255, 0, 255)), "背景圆不许和医生的紫色轮廓撞色" + cyan = np.all(out == np.array((255, 255, 0), dtype=np.uint8), axis=-1) + assert np.where(cyan)[1].max() < 100 + + +def test_reference_region_not_drawn_when_disabled() -> None: + """reference enabled=False 时不画那个圆。""" + layers = _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + source_rect=_LEFT_RECT, + reference_region={"enabled": False, "center_xy": [40.0, 40.0], "radius_px": 12}, + ) + out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) + assert not _has_color(out, (255, 255, 0)) + + +def test_fill_polygon_alpha_blends_target() -> None: + """开启 fill_polygon 时 ROI 内部做 alpha=0.18 混合:黑底上出现暗绿填充, + 且其值约等于 round(255*0.18)=46(绿通道),区别于不填充时的纯黑内部。""" + layers = _layers( + target_polygon=_square_polygon(20, 20, 50), + target_rect=_RIGHT_RECT, + ) + filled = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(fill_polygon=True)) + # 取多边形内部一点(局部 (40,40) -> 整帧 (140,40)),应为混合后的暗绿。 + inner = filled[40, 140] + assert inner[0] == 0 # B 通道仍为 0 + assert 40 <= int(inner[1]) <= 52 # G 通道 ≈ 255*0.18 + assert inner[2] == 0 # R 通道仍为 0 + + +def test_render_overlay_returns_decodable_mp4_bytes() -> None: + """render_overlay 返回非空 mp4 字节;cv2 读回帧数 / 尺寸与输入一致。""" + num_frames = 5 + frames = [_black_frame() for _ in range(num_frames)] + per_frame_layers = [ + _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + ) + for _ in range(num_frames) + ] + data = render_overlay( + frames, + per_frame_layers, + fps=25.0, + cfg=_overlay_cfg(), + frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), + ) + assert isinstance(data, bytes) + assert len(data) > 0 + + # 写回临时文件用 cv2 读回校验(mp4v 编码无法直接 in-memory 解码)。 + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle: + handle.write(data) + probe_path = Path(handle.name) + try: + cap = cv2.VideoCapture(str(probe_path)) + try: + assert cap.isOpened() + assert int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) == _FRAME_WIDTH + assert int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) == _FRAME_HEIGHT + decoded = 0 + while True: + ok, _ = cap.read() + if not ok: + break + decoded += 1 + assert decoded == num_frames + finally: + cap.release() + finally: + probe_path.unlink(missing_ok=True) + + +def test_render_overlay_cleans_temp_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """渲染走临时文件再读回,结束后临时目录不留 .mp4 残留。""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + assert _count_mp4_in(tmp_path) == 0 + frames = [_black_frame() for _ in range(3)] + per_frame_layers = [ + _layers( + target_polygon=_square_polygon(20, 20, 40), + target_rect=_RIGHT_RECT, + ) + for _ in range(3) + ] + render_overlay( + frames, + per_frame_layers, + fps=25.0, + cfg=_overlay_cfg(), + frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), + ) + assert _count_mp4_in(tmp_path) == 0 + + +def test_render_overlay_rejects_non_positive_fps() -> None: + """fps 非正应直接报错(不静默回退 25.0,见 findings §2E / 项目防御原则)。""" + frames = [_black_frame()] + per_frame_layers = [ + _layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT) + ] + with pytest.raises(ValueError, match="fps"): + render_overlay( + frames, + per_frame_layers, + fps=0.0, + cfg=_overlay_cfg(), + frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), + ) + + +def test_render_overlay_rejects_length_mismatch() -> None: + """帧序列与逐帧图层序列长度不一致是调用方缺陷,直接报错。""" + frames = [_black_frame(), _black_frame()] + per_frame_layers = [ + _layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT) + ] + # 流式之后这条由 zip(strict=True) 执行。**仍旧钉住是哪一条报的**:这个函数里有四条 + # 路径抛 ValueError(fps / frame_size / 图层为空 / 长度不一致),裸 raises 会让其中 + # 任何一条被写错都从这里溜过去。 + with pytest.raises(ValueError, match=r"zip\(\)"): + render_overlay( + frames, + per_frame_layers, + fps=25.0, + cfg=_overlay_cfg(), + frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), + ) + + +def test_render_overlay_rejects_empty_frames() -> None: + """一帧都没有就产不出视频,直接报错(不静默产出空视频)。 + + 判据从「帧序列为空」改成「逐帧图层为空」:流式之后帧数要遍历完才知道,而图层那一侧 + 仍旧是个列表,长度当场可查。两者必须等长(由 zip 的 strict 执行),所以查哪一侧等价。 + """ + with pytest.raises(ValueError, match="不能为空"): + render_overlay( + [], + [], + fps=25.0, + cfg=_overlay_cfg(), + frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), + ) + + +def test_render_overlay_rejects_non_positive_frame_size() -> None: + """输出尺寸由调用方给,给错了当场报错——编码器拿非法尺寸只会打不开,消息里看不出是谁的错。""" + frames = [_black_frame()] + layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)] + with pytest.raises(ValueError, match="frame_size"): + render_overlay(frames, layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(0, 10)) + + +def test_render_overlay_拒绝尺寸和_frame_size_对不上的帧() -> None: + """**尺寸不符的帧会被编码器静默丢掉**——实测写 6 帧读回 3 帧,只在 stderr 留一行警告。 + + 后果是 overlay 视频短一截甚至是空的,而它照样有字节、照样被上传、台账照样记完成。 + 输出尺寸以前取自首帧,结构上不可能不符;改成由调用方给之后这条得自己守 + (design/0024 落地后的独立评审报出)。 + """ + layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)] + with pytest.raises(ValueError, match="帧尺寸"): + render_overlay( + [_black_frame()], + layers, + fps=25.0, + cfg=_overlay_cfg(), + # 宽高对调:合法的正尺寸,但和实际帧对不上 + frame_size=(_FRAME_HEIGHT, _FRAME_WIDTH), + ) + + +def test_render_overlay_接受迭代器而不只是列表() -> None: + """**这条守的是流式本身**:服务喂进来的是解码器的生成器,不是列表(design/0024)。 + + 改回 ``Sequence`` 的话,服务那边要么先把全片物化成列表(4.33 GB,正是要消灭的东西), + 要么当场报 TypeError;后者会被别的测试抓到,前者不会。 + """ + layers = [ + _layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT) + for _ in range(3) + ] + data = render_overlay( + (_black_frame() for _ in range(3)), + layers, + fps=25.0, + cfg=_overlay_cfg(), + frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), + ) + assert isinstance(data, bytes) and len(data) > 0 diff --git a/tests/test_ceus_perfusion_metrics.py b/tests/test_ceus_perfusion_metrics.py new file mode 100644 index 0000000..a278873 --- /dev/null +++ b/tests/test_ceus_perfusion_metrics.py @@ -0,0 +1,161 @@ +"""Lognormal 拟合派生灌注指标的纯算法单元测试(照搬旧项目 test_perfusion_metrics.py)。""" + +from dataclasses import replace + +import numpy as np +import pytest + +from cadx.ceus import ( + CeusConfig, + LognormalFitResult, + LognormalParameters, + compute_lognormal_perfusion_metrics, + evaluate_lognormal, + fit_lognormal_curve, +) +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为读 config/ceus.yaml 的取值。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) + + +@pytest.fixture(scope="module") +def complete_fit() -> LognormalFitResult: + times = np.linspace(0.0, 60.0, 601) + parameters = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0) + fit = fit_lognormal_curve( + times, + evaluate_lognormal(times, parameters), + baseline=parameters.baseline, + baseline_noise=0.2, + baseline_end_index=20, + duplicate_mask=np.zeros(times.size, dtype=bool), + sample_mask=None, + cfg=_CONFIG.tic_analysis.fitting, + anchor_cfg=_CONFIG.tic_analysis.anchor_frames, + ) + assert fit.success is True + assert fit.d4_time_s is not None + return fit + + +def test_complete_bolus_metrics_match_curve_integrals( + complete_fit: LognormalFitResult, +) -> None: + metrics = compute_lognormal_perfusion_metrics(complete_fit) + + assert metrics is not None + assert complete_fit.parameters is not None + assert complete_fit.d2_time_s is not None + assert complete_fit.d3_time_s is not None + assert complete_fit.d4_time_s is not None + assert metrics.ti_s is not None + assert metrics.to_s is not None + assert metrics.mtt_s is not None + assert metrics.wi_auc_au_s is not None + assert metrics.wo_auc_au_s is not None + assert metrics.wiwo_auc_au_s is not None + assert metrics.rt_s is not None + assert metrics.ft_s is not None + assert metrics.mttl_s is not None + assert metrics.wipi_au is not None + assert metrics.wor_au_per_s is not None + + assert ( + complete_fit.fit_start_s + <= metrics.ti_s + < complete_fit.d2_time_s + < metrics.ttp_s + < complete_fit.d4_time_s + < metrics.to_s + <= complete_fit.fit_end_s + ) + assert metrics.pe_au == pytest.approx( + float( + evaluate_lognormal(np.asarray([metrics.ttp_s]), complete_fit.parameters)[0] + - complete_fit.parameters.baseline + ) + ) + assert metrics.ttp_s == pytest.approx(complete_fit.d3_time_s) + assert metrics.wir_au_per_s > 0.0 + assert metrics.wor_au_per_s < 0.0 + assert metrics.rt_s == pytest.approx(metrics.ttp_s - metrics.ti_s) + assert metrics.ft_s == pytest.approx(metrics.to_s - metrics.ttp_s) + assert metrics.wiwo_auc_au_s == pytest.approx(metrics.wi_auc_au_s + metrics.wo_auc_au_s) + assert metrics.wipi_au == pytest.approx(metrics.wi_auc_au_s / metrics.rt_s) + assert metrics.mttl_s == pytest.approx(metrics.mtt_s - metrics.ti_s) + assert metrics.ti_s < metrics.mtt_s < metrics.to_s + + grid = np.linspace(metrics.ti_s, metrics.to_s, 100_001) + pulse = evaluate_lognormal(grid, complete_fit.parameters) - (complete_fit.parameters.baseline) + split = int(np.searchsorted(grid, metrics.ttp_s)) + wi_grid = np.concatenate((grid[:split], np.asarray([metrics.ttp_s]))) + wo_grid = np.concatenate((np.asarray([metrics.ttp_s]), grid[split:])) + wi_pulse = evaluate_lognormal(wi_grid, complete_fit.parameters) - ( + complete_fit.parameters.baseline + ) + wo_pulse = evaluate_lognormal(wo_grid, complete_fit.parameters) - ( + complete_fit.parameters.baseline + ) + assert metrics.wi_auc_au_s == pytest.approx(np.trapezoid(wi_pulse, wi_grid), rel=1e-7) + assert metrics.wo_auc_au_s == pytest.approx(np.trapezoid(wo_pulse, wo_grid), rel=1e-7) + assert metrics.mtt_s == pytest.approx( + np.trapezoid(grid * pulse, grid) / np.trapezoid(pulse, grid), rel=1e-7 + ) + + +def test_missing_washout_keeps_only_wash_in_metrics( + complete_fit: LognormalFitResult, +) -> None: + metrics = compute_lognormal_perfusion_metrics(replace(complete_fit, d4_time_s=None)) + + assert metrics is not None + assert metrics.pe_au > 0.0 + assert metrics.ttp_s > 0.0 + assert metrics.wir_au_per_s > 0.0 + assert metrics.wi_auc_au_s is not None + assert metrics.rt_s is not None + assert metrics.wipi_au is not None + assert metrics.wor_au_per_s is None + assert metrics.wo_auc_au_s is None + assert metrics.wiwo_auc_au_s is None + assert metrics.ft_s is None + assert metrics.mttl_s is None + assert metrics.to_s is None + assert metrics.mtt_s is None + + +def test_failed_fit_has_no_metrics(complete_fit: LognormalFitResult) -> None: + assert compute_lognormal_perfusion_metrics(replace(complete_fit, success=False)) is None + + +def test_tangent_intersections_outside_observation_gate_dependents( + complete_fit: LognormalFitResult, +) -> None: + complete = compute_lognormal_perfusion_metrics(complete_fit) + assert complete is not None + assert complete.ti_s is not None + assert complete.to_s is not None + + missing_wash_in = compute_lognormal_perfusion_metrics( + replace(complete_fit, fit_start_s=complete.ti_s + 0.01) + ) + assert missing_wash_in is not None + assert missing_wash_in.ti_s is None + assert missing_wash_in.rt_s is None + assert missing_wash_in.wi_auc_au_s is None + assert missing_wash_in.wipi_au is None + assert missing_wash_in.wo_auc_au_s is not None + assert missing_wash_in.wiwo_auc_au_s is None + assert missing_wash_in.mttl_s is None + + missing_wash_out = compute_lognormal_perfusion_metrics( + replace(complete_fit, fit_end_s=complete.to_s - 0.01) + ) + assert missing_wash_out is not None + assert missing_wash_out.wor_au_per_s is not None + assert missing_wash_out.to_s is None + assert missing_wash_out.ft_s is None + assert missing_wash_out.wo_auc_au_s is None + assert missing_wash_out.wiwo_auc_au_s is None + assert missing_wash_out.mttl_s is None diff --git a/tests/test_ceus_pipeline.py b/tests/test_ceus_pipeline.py new file mode 100644 index 0000000..5c134bd --- /dev/null +++ b/tests/test_ceus_pipeline.py @@ -0,0 +1,168 @@ +"""ceus 对外两段接口的端到端测试:合成一段分屏造影视频,第二段喂合成掩膜(不调分割模型)。 + +视频 400×200、10 fps、40 秒(400 帧),分屏线在 x=200(这一列全黑)。造影画面在右半:病灶是整帧 +[250, 369] × [60, 159] 的方块,医生的绿框是它的空心描边;病灶内部亮度按 Lognormal 团注曲线从 +基线 40 升到约 170 再回落,内部另有一块一直发黑的囊(暗区);每帧加一点随机噪声,免得相邻帧 +被当成重复帧。左半是不变的灰阶画面。 + +手算的预期: +- 绿框在右半 → 造影画面是右半,分割那一半是左半 [0, 200); +- 绿框挪到左半局部坐标:x = 250 − 200 = 50 到 369 − 200 = 169; +- 推注时间 X = 5 秒 → F1 在视频第 20 − 5 = 15 秒(第 150 帧),F2 在第 60 − 5 = 55 秒, + 超过 40 秒的视频末尾,未定位。 +""" + +import json +import math +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from cadx.ceus import ( + ANCHOR_SLOTS, + CeusConfig, + LognormalParameters, + NoValidContourError, + SegmentationInput, + evaluate_lognormal, + keyframe_images, + pack_tic_curves, + pack_trajectory, + prepare_segmentation, + quant_summary, + quantify_case, + render_case_overlay, + render_tic_png, + tic_plot_markers, + unpack_tic_curves, + unpack_trajectory, +) + +_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200 +_FPS, _FRAMES = 10.0, 400 +_LESION = (250, 60, 369, 159) # 整帧坐标 x1, y1, x2, y2(含端点) +_CYST = (290, 95, 320, 125) +_BOLUS = LognormalParameters(area=1300.0, mu=2.4, sigma=0.45, arrival_offset_s=4.0, baseline=40.0) +_INJECTION_OFFSET_S = 5.0 + + +def _write_video(path: Path) -> None: + rng = np.random.default_rng(3) + times = np.arange(_FRAMES) / _FPS + intensity = evaluate_lognormal(times, _BOLUS) + writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), _FPS, (_WIDTH, _HEIGHT)) + x1, y1, x2, y2 = _LESION + for index in range(_FRAMES): + frame = np.zeros((_HEIGHT, _WIDTH, 3), dtype=np.uint8) + frame[:, :_SPLIT] = 120 + frame[:, _SPLIT + 1 :] = 30 + lesion = np.clip( + intensity[index] + rng.normal(0.0, 6.0, (y2 - y1 + 1, x2 - x1 + 1)), 0, 255 + ) + frame[y1 : y2 + 1, x1 : x2 + 1] = lesion.astype(np.uint8)[:, :, None] + cx1, cy1, cx2, cy2 = _CYST + frame[cy1 : cy2 + 1, cx1 : cx2 + 1] = 10 + cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), thickness=3) + writer.write(frame) + writer.release() + + +@pytest.fixture(scope="module") +def config() -> CeusConfig: + from conftest import load_ceus_section + + return CeusConfig.model_validate(load_ceus_section()) + + +@pytest.fixture(scope="module") +def prepared(tmp_path_factory: pytest.TempPathFactory, config: CeusConfig) -> SegmentationInput: + path = tmp_path_factory.mktemp("ceus") / "synthetic.mp4" + _write_video(path) + return prepare_segmentation(path, config) + + +def _lesion_masks(prepared: SegmentationInput) -> np.ndarray: + """合成「分割结果」:每帧都是病灶方块挪到左半局部坐标后的位置(略往里收 3 像素)。""" + view = prepared.contrast.target_view + masks = np.zeros((prepared.frame_count, view.height, view.width), dtype=bool) + x1, y1, x2, y2 = _LESION + masks[:, y1 + 3 : y2 - 2, x1 - _SPLIT + 3 : x2 - _SPLIT - 2] = True + return masks + + +def test_prepare_segmentation_finds_contrast_half_and_maps_prompts( + prepared: SegmentationInput, +) -> None: + assert prepared.frame_count == _FRAMES + assert prepared.frame_size == (_WIDTH, _HEIGHT) + assert prepared.contrast.side == "right" + target = prepared.contrast.target_view + assert (target.x1, target.x2) == (0, prepared.split_x) + assert abs(prepared.split_x - _SPLIT) <= 1 + assert len(prepared.target_frames) == _FRAMES + assert all(frame.shape == (target.height, target.width, 3) for frame in prepared.target_frames) + assert sorted(prepared.prompt_masks) == [p.frame_index for p in prepared.contrast.prompt_frames] + assert 0 in prepared.prompt_masks + xs = np.where(prepared.prompt_masks[0])[1] + # 描边 3 像素宽、找绿框时再膨胀 2 像素、mp4 压缩会糊开边缘,容差放到 5 像素。 + assert abs(int(xs.min()) - 50) <= 5 + assert abs(int(xs.max()) - 169) <= 5 + + +def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfig) -> None: + result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config) + + assert result.trajectory.source_polygons.shape == (_FRAMES, 64, 2) + assert result.lesion_fit_ready is True + assert list(result.anchors) == list(ANCHOR_SLOTS) + for slot in ("D1", "D2", "D3"): + assert result.anchors[slot].status == "ok", result.anchors[slot].reason + assert result.anchors["F1"].frame_index == 150 + assert result.anchors["F1"].frame_time_video_s == pytest.approx(15.0, abs=0.06) + assert result.anchors["F2"].status == "not_covered" + assert result.anchors["F2"].reason is not None + assert result.perfusion["roi_perf"] is not None + peak_time = _BOLUS.arrival_offset_s + math.exp(_BOLUS.mu - _BOLUS.sigma**2) + assert result.perfusion["roi_perf"].ttp_s == pytest.approx(peak_time, abs=0.5) + # 画面上没有紫圈。 + assert result.pan_detected_fraction == 0.0 + assert result.tic.reference_valid is False + # 暗区(囊)被找出来,去暗区曲线比病灶整体亮。 + peak_row = result.tic.rows[round(peak_time * _FPS)] + assert peak_row["dark_area_px"] > 0 + assert peak_row["roi_perf_raw"] > peak_row["roi_all_raw"] + + restored = unpack_trajectory(pack_trajectory(result.trajectory)) + assert restored.calibration["contrast_side"] == "right" + curves = unpack_tic_curves(pack_tic_curves(result.curves)) + assert curves.injection_offset_s == pytest.approx(_INJECTION_OFFSET_S) + + summary = quant_summary(result) + json.dumps(summary, allow_nan=False) + assert summary["anchor_frames"]["F1"]["frame_index"] == 150 + assert summary["perfusion_metrics"]["roi_perf"] is not None + + markers = tic_plot_markers(result.anchors) + assert [marker.label.split()[0] for marker in markers][:3] == ["D1", "D2", "D3"] + assert render_tic_png(result.tic, markers=markers).startswith(b"\x89PNG") + assert len(render_case_overlay(prepared, result)) > 0 + images = keyframe_images(prepared, result.anchors) + assert "F1" in images and "F2" not in images + assert all(image.shape == (_HEIGHT, _WIDTH, 3) for image in images.values()) + + +def test_empty_segmentation_fails_case_at_step_4( + prepared: SegmentationInput, config: CeusConfig +) -> None: + empty = np.zeros_like(_lesion_masks(prepared)) + with pytest.raises(NoValidContourError) as raised: + quantify_case(prepared, empty, _INJECTION_OFFSET_S, config) + assert raised.value.step == 4 + + +def test_mask_shape_mismatch_is_rejected(prepared: SegmentationInput, config: CeusConfig) -> None: + masks = _lesion_masks(prepared)[:-1] + with pytest.raises(ValueError, match="分割掩膜"): + quantify_case(prepared, masks, _INJECTION_OFFSET_S, config) diff --git a/tests/test_ceus_polygons.py b/tests/test_ceus_polygons.py new file mode 100644 index 0000000..0d88f59 --- /dev/null +++ b/tests/test_ceus_polygons.py @@ -0,0 +1,508 @@ +"""polygons 的单元测试(照搬旧项目 test_ceus_polygons.py;坐标换算改为两个视图矩形, +去掉旧的有效区矩形和映射锚点参数)。 + +旧标题:B6 polygons.py 的单元测试:mask→平滑多边形 + 位置约束 + 绿框先验融合。 + +参考 ``references/ROI/roi_tracking_pipeline.py`` P:649-965。覆盖核心真实场景 +(边缘场景靠实现内 assertion / 守卫,不在此堆假数据): + +- ``masks_to_smoothed_polygons``:圆形 mask → (N, 64, 2) 平滑多边形;**空 mask 帧 + 保留在序列**(§5 帧对齐不变量,N 帧进 N 帧出),且其多边形由时间插值得到(非空)。 +- ``apply_prompt_frame_position_constraints``(strength=1.0):把每帧质心拉到 keyframe + 中心插值轨迹上(整体平移),**不改面积**;``enabled=False`` 原样返回。 +- ``fuse_prompt_prior_with_medsam``:``source != "prompt_prior"`` 门关 → 直接返回纯 + medsam;门开时 keyframe 帧用 ``keyframe_shape_strength`` 逐顶点混合,且面积被 + 收紧到 ``[min, max] × prior_area``(findings §3.7)。 +""" + +from dataclasses import dataclass + +import cv2 +import numpy as np +import pytest + +from cadx.ceus import ( + CeusConfig, + ViewRect, + apply_prompt_frame_position_constraints, + fuse_prompt_prior_with_medsam, + masks_to_smoothed_polygons, + polygon_area, +) +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) + + +@dataclass +class _RawShapeCfg: + """绕过 RoiShapeConfig 校验器的鸭子接口(直接喂超界 strength,验证函数内部 clip)。""" + + source: str = "prompt_prior" + keyframe_shape_strength: float = 1.0 + propagation_shape_strength: float = 0.75 + min_area_ratio_to_prompt: float = 0.70 + max_area_ratio_to_prompt: float = 1.25 + + +@dataclass +class _RawConstraintCfg: + """绕过 KeyframePositionConstraint 校验器(Literal mode + strength clip)的鸭子接口。""" + + enabled: bool = True + mode: str = "center_interpolation" + strength: float = 1.0 + + +def _disk_mask(height: int, width: int, center_xy: tuple[int, int], radius: int) -> np.ndarray: + """生成 ``(height, width)`` 的圆盘二值 mask(前景为 True),供轮廓提取测试用。""" + canvas = np.zeros((height, width), dtype=np.uint8) + cv2.circle(canvas, (int(center_xy[0]), int(center_xy[1])), int(radius), 255, -1) + return canvas > 0 + + +def _circle_polygon(center_xy: tuple[float, float], radius: float, num_points: int) -> np.ndarray: + """生成绕指定中心的规则圆多边形 ``(num_points, 2)``(用作 medsam / prior 测试输入)。""" + angles = np.linspace(0.0, 2.0 * np.pi, num_points, endpoint=False) + poly = np.zeros((num_points, 2), dtype=np.float32) + poly[:, 0] = center_xy[0] + radius * np.cos(angles) + poly[:, 1] = center_xy[1] + radius * np.sin(angles) + return poly + + +def test_masks_to_smoothed_polygons_shape_and_frame_alignment() -> None: + """圆盘 mask → (N, 64, 2);中间空 mask 帧保留在序列(§5 帧对齐不变量)。""" + frame_count = 5 + contour_points = _CONFIG.smoothing.contour_points # 64 + masks_by_frame = { + 0: _disk_mask(120, 120, (60, 60), 20), + 1: _disk_mask(120, 120, (62, 60), 20), + # frame 2 空(退化 / 漏检)——不得剔除,靠时间插值填补 + 3: _disk_mask(120, 120, (64, 60), 20), + 4: _disk_mask(120, 120, (66, 60), 20), + } + + polygons = masks_to_smoothed_polygons(masks_by_frame, frame_count, _CONFIG.smoothing) + + assert len(polygons) == frame_count, "N 帧进必须 N 帧出(§5 帧对齐,不剔除空帧)。" + for poly in polygons: + assert poly.shape == (contour_points, 2) + assert poly.dtype == np.float32 + # 空帧(index 2)由相邻帧时间插值得到,应为有效非退化多边形 + assert polygon_area(polygons[2]) > 1.0, "空 mask 帧应被时间插值填补为有效多边形。" + + +def test_masks_to_smoothed_polygons_all_empty_raises() -> None: + """全部帧无有效轮廓时无从插值,直接 RuntimeError(不静默兜底)。""" + import pytest + + with pytest.raises(RuntimeError): + masks_to_smoothed_polygons({}, 3, _CONFIG.smoothing) + + +def test_position_constraint_pulls_center_to_keyframe_track_without_area_change() -> None: + """strength=1.0 把每帧质心拉到 keyframe 中心插值轨迹,且整体平移不改面积。""" + target_eff = ViewRect(0, 0, 200, 200) + source_view = ViewRect(0, 0, 200, 200) + + # 两个 keyframe:source mask 中心分别在 (40, 100) 与 (160, 100) + keyframes = [ + {"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)}, + {"frame_index": 4, "mask": _disk_mask(200, 200, (160, 100), 15)}, + ] + # 当前多边形都偏在 (100, 30),期望被拉到插值中心轨迹(x 从 40 线性到 160) + polygons = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(5)] + areas_before = [polygon_area(poly) for poly in polygons] + + corrected = apply_prompt_frame_position_constraints( + polygons, + keyframes, + source_view, + target_eff, + _CONFIG.constraints.keyframe_position, # enabled、center_interpolation、strength=1.0 + ) + + assert len(corrected) == len(polygons) + # frame 0 质心 ≈ (40, 100),frame 4 ≈ (160, 100),frame 2 ≈ (100, 100)(线性插值) + expected_x = [40.0, 70.0, 100.0, 130.0, 160.0] + for frame_idx, poly in enumerate(corrected): + center = poly.mean(axis=0) + assert abs(float(center[0]) - expected_x[frame_idx]) < 3.0 + assert abs(float(center[1]) - 100.0) < 3.0 + # 整体平移:面积不变 + for before, poly in zip(areas_before, corrected, strict=True): + assert abs(polygon_area(poly) - before) < 2.0 + + +def test_position_constraint_disabled_returns_input() -> None: + """enabled=False 时原样返回输入(门关)。""" + target_eff = ViewRect(0, 0, 100, 100) + source_view = ViewRect(0, 0, 100, 100) + polygons = [_circle_polygon((50.0, 50.0), 10.0, 64)] + keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}] + + out = apply_prompt_frame_position_constraints( + polygons, + keyframes, + source_view, + target_eff, + _CONFIG.constraints.keyframe_position.model_copy(update={"enabled": False}), + ) + + assert out is polygons + + +def test_fusion_gate_closed_returns_pure_medsam() -> None: + """roi_shape.source != 'prompt_prior' → 直接返回纯 medsam(门关,findings §3.7)。""" + target_eff = ViewRect(0, 0, 100, 100) + source_view = ViewRect(0, 0, 100, 100) + medsam = [_circle_polygon((50.0, 50.0), 10.0, 64)] + keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}] + + out = fuse_prompt_prior_with_medsam( + medsam, + keyframes, + source_view, + target_eff, + _CONFIG.roi_shape.model_copy(update={"source": "medsam2"}), + _CONFIG.smoothing, + ) + + assert out is medsam + + +def test_fusion_keyframe_strength_one_yields_prior_shape() -> None: + """keyframe 帧 strength=1.0:融合结果几何上贴近绿框先验(逐顶点混合 s=1)。""" + target_eff = ViewRect(0, 0, 200, 200) + source_view = ViewRect(0, 0, 200, 200) + + # 绿框先验中心 (100, 100) 半径 30;medsam 偏移到 (130, 130) 半径 30 + keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 30)}] + medsam = [_circle_polygon((130.0, 130.0), 30.0, 64)] + + fused = fuse_prompt_prior_with_medsam( + medsam, + keyframes, + source_view, + target_eff, + _CONFIG.roi_shape.model_copy(update={"keyframe_shape_strength": 1.0}), + _CONFIG.smoothing, + ) + + assert len(fused) == 1 + center = fused[0].mean(axis=0) + # s=1 → 结果应贴近先验中心 (100, 100) 而非 medsam 中心 (130, 130) + assert abs(float(center[0]) - 100.0) < 8.0 + assert abs(float(center[1]) - 100.0) < 8.0 + + +def test_fusion_area_clamped_to_prior_band() -> None: + """融合面积超过 max_area_ratio_to_prompt 时按 sqrt 绕先验质心缩放回界(findings §3.7)。""" + target_eff = ViewRect(0, 0, 400, 400) + source_view = ViewRect(0, 0, 400, 400) + + # 关键帧只在 frame 0;frame 1 为传播帧(非 keyframe),用 propagation_shape_strength=0 + # 让 medsam 形状主导,从而触发面积收紧。先验半径 30,medsam 半径 80(面积远超 1.25×)。 + keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 30)}] + medsam = [ + _circle_polygon((200.0, 200.0), 30.0, 64), # frame 0(keyframe,对照) + _circle_polygon((200.0, 200.0), 80.0, 64), # frame 1(propagation,测收紧) + ] + + shape_cfg = _CONFIG.roi_shape.model_copy( + update={ + "keyframe_shape_strength": 1.0, + "propagation_shape_strength": 0.0, # frame 1 形状 = 纯 medsam(半径80,超界) + "min_area_ratio_to_prompt": 0.70, + "max_area_ratio_to_prompt": 1.25, + } + ) + + fused = fuse_prompt_prior_with_medsam( + medsam, + keyframes, + source_view, + target_eff, + shape_cfg, + _CONFIG.smoothing, + ) + + # 真实先验面积取自 frame 0(keyframe,s=1 → 形状=离散化绿框先验),避免用解析圆面积失真 + prior_area = polygon_area(fused[0]) + fused_area = polygon_area(fused[1]) + # frame 1 s=0 → 形状=medsam(半径80,面积远超界),应被收紧到 ≈ max_ratio × prior_area + assert fused_area <= prior_area * 1.25 + 50.0, "超界面积应被收紧到 max_area_ratio。" + assert fused_area >= prior_area * 1.25 - 100.0, "应恰好收到上界附近,而非过度收缩。" + + +# Phase 4b: fuse_prompt_prior_with_medsam —— 关键证伪路径 + + +def test_fusion_propagation_frame_linear_blend_without_tightening() -> None: + """非 keyframe 帧 + 0 None: + """下界收紧:medsam 面积 < min_ratio × prior 面积 → 被放大到 ≈ min_ratio × prior。""" + target_eff = ViewRect(0, 0, 400, 400) + source_view = ViewRect(0, 0, 400, 400) + + # 先验半径 60;medsam 传播帧半径 20(面积比≈0.11 < min_ratio 0.70 → 触发下界放大) + keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 60)}] + medsam = [ + _circle_polygon((200.0, 200.0), 60.0, 64), # frame 0(keyframe,对照) + _circle_polygon((200.0, 200.0), 20.0, 64), # frame 1(传播帧,面积过小) + ] + + shape_cfg = _RawShapeCfg( + keyframe_shape_strength=1.0, + propagation_shape_strength=0.0, # frame 1 形状 = 纯 medsam(半径20,欠界) + min_area_ratio_to_prompt=0.70, + max_area_ratio_to_prompt=1.25, + ) + + fused = fuse_prompt_prior_with_medsam( + medsam, + keyframes, + source_view, + target_eff, + shape_cfg, + _CONFIG.smoothing, + ) + + prior_area = polygon_area(fused[0]) + fused_area = polygon_area(fused[1]) + # 欠界面积应被放大到 ≈ min_ratio × prior_area(绕先验质心 sqrt 缩放) + assert fused_area >= prior_area * 0.70 - 100.0, "欠界面积应被放大到 min_area_ratio。" + assert fused_area <= prior_area * 0.70 + 200.0, "应恰好放大到下界附近,而非过度膨胀。" + + +def test_fusion_start_point_misalignment_does_not_distort_shape() -> None: + """medsam 与 prior 起点错位但形状相同:融合后不因起点错位畸变(输出仍贴近该形状)。 + + 把 medsam 顶点循环移位(起点错位)但形状 / 中心与 prior 完全一致,s=0.5: + 若不先对齐起点,逐顶点混合会把不同角度的点平均出来导致半径塌缩;align 后应保形。 + """ + target_eff = ViewRect(0, 0, 400, 400) + source_view = ViewRect(0, 0, 400, 400) + + radius = 50.0 + keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), int(radius))}] + base_circle = _circle_polygon((200.0, 200.0), radius, 64) + shifted_circle = np.roll(base_circle, 17, axis=0) # 起点错位 17 个顶点 + medsam = [ + _circle_polygon((200.0, 200.0), radius, 64), # frame 0(keyframe,对照) + shifted_circle, # frame 1(传播帧,起点错位) + ] + + shape_cfg = _RawShapeCfg( + keyframe_shape_strength=1.0, + propagation_shape_strength=0.5, + min_area_ratio_to_prompt=0.10, + max_area_ratio_to_prompt=2.00, # 放宽上界,孤立起点对齐效应 + ) + + fused = fuse_prompt_prior_with_medsam( + medsam, + keyframes, + source_view, + target_eff, + shape_cfg, + _CONFIG.smoothing, + ) + + prior_area = polygon_area(fused[0]) + fused_area = polygon_area(fused[1]) + # 起点对齐后形状不塌缩:融合面积应与先验面积相当(错位若未对齐会显著缩小) + assert fused_area == pytest.approx(prior_area, rel=0.10), ( + f"起点错位经 align 后应保形(≈{prior_area:.0f}),实得 {fused_area:.0f}。" + ) + + +def test_fusion_propagation_strength_clipped_to_unit_interval() -> None: + """传播权重越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。""" + target_eff = ViewRect(0, 0, 400, 400) + source_view = ViewRect(0, 0, 400, 400) + + keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 40)}] + medsam = [ + _circle_polygon((200.0, 200.0), 40.0, 64), # frame 0(keyframe) + _circle_polygon((260.0, 260.0), 40.0, 64), # frame 1(传播帧,偏移到右下) + ] + + def _fuse(propagation_strength: float) -> np.ndarray: + shape_cfg = _RawShapeCfg( + keyframe_shape_strength=1.0, + propagation_shape_strength=propagation_strength, + min_area_ratio_to_prompt=0.10, + max_area_ratio_to_prompt=2.00, + ) + return fuse_prompt_prior_with_medsam( + medsam, + keyframes, + source_view, + target_eff, + shape_cfg, + _CONFIG.smoothing, + )[1] + + # s=1.5 被 clip 到 1.0 → 形状=纯 prior(中心贴近 (200,200));与显式 1.0 等价 + over = _fuse(1.5).mean(axis=0) + one = _fuse(1.0).mean(axis=0) + assert np.allclose(over, one, atol=1.0), "s=1.5 应被 clip 到 1.0(行为等价)。" + # s=−0.5 被 clip 到 0.0 → 形状=纯 medsam(中心贴近 (260,260));与显式 0.0 等价 + neg = _fuse(-0.5).mean(axis=0) + zero = _fuse(0.0).mean(axis=0) + assert np.allclose(neg, zero, atol=1.0), "s=−0.5 应被 clip 到 0.0(行为等价)。" + + +# Phase 5: 时间平滑 + 位置约束补测 + + +def test_temporal_smoothing_damps_per_vertex_jitter_along_time() -> None: + """逐帧抖动的顶点序列经时间轴三角平滑后抖动被削弱、形状趋稳。 + + 构造一段「真值恒定 + 逐帧高频抖动」的多边形序列(顶点已逐点对应), + masks_to_smoothed_polygons 内的时间平滑应显著降低逐帧位移幅度。 + """ + frame_count = 9 + base_center = (200, 200) + radius = 30 + rng = np.random.default_rng(7) + + # 每帧在固定圆心附近抖动 ±4px(mask 级别抖动 → 平滑后中心轨迹应更平稳) + masks_by_frame: dict[int, np.ndarray] = {} + for frame_idx in range(frame_count): + jitter = rng.integers(-4, 5, size=2) + center = (int(base_center[0] + jitter[0]), int(base_center[1] + jitter[1])) + masks_by_frame[frame_idx] = _disk_mask(400, 400, center, radius) + + polygons = masks_to_smoothed_polygons( + masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 5}) + ) + + # 平滑后逐帧质心位移幅度应小于原始抖动尺度(抖动被削弱) + centers = np.array([poly.mean(axis=0) for poly in polygons]) + frame_to_frame_shift = np.linalg.norm(np.diff(centers, axis=0), axis=1) + assert frame_to_frame_shift.max() < 4.0, "时间平滑应把逐帧质心抖动削弱到原始 ±4px 尺度以下。" + # 形状趋稳:各帧面积方差小(半径恒定 → 平滑不应制造面积剧烈起伏) + areas = np.array([polygon_area(poly) for poly in polygons]) + assert areas.std() / areas.mean() < 0.10, "恒定半径下平滑后面积应稳定。" + + +def test_temporal_smoothing_no_vertex_crossing_distortion() -> None: + """相邻帧轮廓起点对齐使逐点对应,时间平滑不产生顶点错位畸变(形状保真)。 + + 两帧同形同位的圆(仅起点天然可能错位),平滑后每帧仍是完整圆而非塌缩折线。 + """ + frame_count = 3 + masks_by_frame = {idx: _disk_mask(400, 400, (200, 200), 40) for idx in range(frame_count)} + + polygons = masks_to_smoothed_polygons( + masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 3}) + ) + + # 起点对齐 + 平滑后:每帧面积应接近真实圆面积,未因顶点错位塌缩 + analytic_area = np.pi * 40.0**2 + for poly in polygons: + assert polygon_area(poly) == pytest.approx(analytic_area, rel=0.10), ( + "起点对齐后时间平滑应保形,不出现顶点错位导致的面积塌缩。" + ) + + +def test_position_constraint_invalid_mode_raises() -> None: + """mode != 'center_interpolation' → RuntimeError(当前仅支持该模式,纵深防御)。""" + target_eff = ViewRect(0, 0, 200, 200) + source_view = ViewRect(0, 0, 200, 200) + polygons = [_circle_polygon((100.0, 100.0), 20.0, 64)] + keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 20)}] + + with pytest.raises(RuntimeError): + apply_prompt_frame_position_constraints( + polygons, + keyframes, + source_view, + target_eff, + _RawConstraintCfg(mode="affine"), + ) + + +def test_position_constraint_strength_clipped_to_unit_interval() -> None: + """strength 越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。""" + target_eff = ViewRect(0, 0, 200, 200) + source_view = ViewRect(0, 0, 200, 200) + keyframes = [ + {"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)}, + {"frame_index": 2, "mask": _disk_mask(200, 200, (160, 100), 15)}, + ] + base_polys = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(3)] + + def _correct(strength: float) -> np.ndarray: + corrected = apply_prompt_frame_position_constraints( + [poly.copy() for poly in base_polys], + keyframes, + source_view, + target_eff, + _RawConstraintCfg(strength=strength), + ) + return np.array([poly.mean(axis=0) for poly in corrected]) + + # s=1.5 → clip 到 1.0(全量拉到 keyframe 轨迹);与显式 1.0 等价 + assert np.allclose(_correct(1.5), _correct(1.0), atol=0.5) + # s=−0.5 → clip 到 0.0(不平移,质心保持原 (100, 30));与显式 0.0 等价 + over_zero = _correct(-0.5) + explicit_zero = _correct(0.0) + assert np.allclose(over_zero, explicit_zero, atol=0.5) + assert np.allclose(over_zero[:, 1], 30.0, atol=2.0), "s=0 不平移,质心 y 应保持 30。" diff --git a/tests/test_ceus_reference_region.py b/tests/test_ceus_reference_region.py new file mode 100644 index 0000000..7294456 --- /dev/null +++ b/tests/test_ceus_reference_region.py @@ -0,0 +1,328 @@ +"""背景圆选择(reference_region)单元测试(照搬旧项目 test_ceus_reference_region.py)。 + +旧标题:B8 参考区选择(reference_region)单元测试。 + +参考区是 ROI 外一块"干净背景"小圆,用于 TIC 的 ``background_circle`` 曲线。本测试 +用真实 OpenCV 像素管线(非 mock)合成"亮 ROI + 四周可控背景"图像,钉死 +``select_reference_region`` 的真实行为,覆盖: + +- ``enabled=False`` → 返回空 mask + ``{"enabled": False}``; +- ``manual`` 模式:用 ``center_xy`` + ``radius_px`` 出圆 mask,metadata 键齐全且 shape=="circle"; +- ``auto_near_roi`` 找到合规背景圆(ROI 外有干净非黑非绿背景); +- ``auto_near_roi`` 四周全黑 / 全绿无合规位 → 明确报错(不返回失真位); +- ``auto_near_roi`` 的干净背景判据把医生画的紫色轮廓也排掉(TIC 会扣掉这些像素)。 +""" + +import numpy as np + +from cadx.ceus import ( + CeusConfig, + ReferenceRegionConfig, + TicAnalysisConfig, + polygon_to_mask, + select_reference_region, +) +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) + + +def _ref_cfg(**overrides: object) -> ReferenceRegionConfig: + """ceus.yaml 的背景圆段,按需改几项(代替旧的 `ReferenceRegionConfig(...)`)。""" + return _CONFIG.tic_analysis.reference_region.model_copy(update=overrides) + + +# ---------- 合成样本 ---------- + +_H, _W = 160, 200 +# ROI 多边形:画面中央偏右上的方块(留出左下大片背景给 auto 模式落圆)。 +_ROI_POLYGON = np.array( + [[110.0, 20.0], [170.0, 20.0], [170.0, 70.0], [110.0, 70.0]], dtype=np.float32 +) + + +# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。 +_PURPLE_BGR = (255, 0, 212) + + +def _source_polygons() -> np.ndarray: + """逐帧 ROI 多边形序列(参考区只看第一帧,单帧即可)。""" + return np.array([_ROI_POLYGON]) + + +def _bright_background_frame() -> np.ndarray: + """整幅亮背景(灰度 180)的 BGR 帧——四周都是干净非黑非绿背景。""" + return np.full((_H, _W, 3), 180, dtype=np.uint8) + + +def _black_frame_with_bright_roi() -> np.ndarray: + """ROI 区域亮(180),ROI 之外全黑——auto 模式四周无合规背景圆。""" + frame = np.zeros((_H, _W, 3), dtype=np.uint8) + frame[20:70, 110:170] = 180 + return frame + + +def _green_background_frame() -> np.ndarray: + """ROI 区亮、四周全是医生绿框色(BGR 纯绿)——auto 模式四周被剔绿后无合规位。""" + frame = np.zeros((_H, _W, 3), dtype=np.uint8) + frame[:, :] = (0, 255, 0) # BGR 纯绿,落在默认 HSV [35,60,60]-[90,255,255] 内 + frame[20:70, 110:170] = 180 + return frame + + +def _tic_cfg(ref: ReferenceRegionConfig) -> TicAnalysisConfig: + """构造带指定 reference_region 子配置的 TicAnalysisConfig。""" + return _CONFIG.tic_analysis.model_copy(update={"reference_region": ref}) + + +# ---------- enabled=False ---------- + + +def test_disabled_returns_empty_mask_and_flag() -> None: + """enabled=False:返回全 False mask 且 metadata 仅含 {"enabled": False}。""" + ref = _ref_cfg(enabled=False) + frame = _bright_background_frame() + + mask, meta = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + assert mask.shape == (_H, _W) + assert mask.dtype == bool + assert not mask.any() + assert meta == {"enabled": False} + + +# ---------- manual 模式 ---------- + + +def test_manual_mode_circle_at_center() -> None: + """manual:以 center_xy + radius_px 出圆 mask,metadata 键齐全且 shape=='circle'。""" + radius = 24 + center = [40.0, 120.0] + ref = _ref_cfg(mode="manual", center_xy=center, radius_px=radius) + frame = _bright_background_frame() + + mask, meta = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + assert mask.dtype == bool + assert mask[int(center[1]), int(center[0])] # 圆心被标记 + assert not mask[0, 0] # 远处角点不在圆内 + assert meta["enabled"] is True + assert meta["mode"] == "manual" + assert meta["shape"] == "circle" + assert meta["center_xy"] == center + assert meta["radius_px"] == float(radius) + assert meta["area_px"] == int(mask.sum()) + assert meta["area_px"] > 0 + + +def test_manual_circle_area_matches_radius() -> None: + """manual 圆面积应接近 π r²(栅格化误差范围内)。""" + radius = 20 + ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=radius) + frame = _bright_background_frame() + + mask, _ = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + expected = np.pi * radius * radius + assert abs(int(mask.sum()) - expected) / expected < 0.1 + + +# ---------- auto_near_roi 找到 ---------- + + +def test_auto_finds_clean_background_circle() -> None: + """auto:四周干净亮背景时应找到合规圆,metadata 键齐全且 valid_fraction 达标。""" + ref = _ref_cfg(mode="auto_near_roi") + frame = _bright_background_frame() + + mask, meta = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + assert mask.dtype == bool + assert mask.any() + assert meta["enabled"] is True + assert meta["mode"] == "auto_near_roi" + assert meta["shape"] == "circle" + assert len(meta["center_xy"]) == 2 + assert meta["radius_px"] == float(ref.radius_px) + assert meta["area_px"] == int(mask.sum()) + assert meta["valid_fraction_first_frame"] >= ref.min_valid_fraction + + +def test_auto_reference_circle_outside_roi() -> None: + """auto:选中的参考圆不应与 ROI(含禁区膨胀)重叠。""" + ref = _ref_cfg(mode="auto_near_roi") + frame = _bright_background_frame() + + mask, _ = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + roi_mask = polygon_to_mask(_ROI_POLYGON, _H, _W) + assert not np.any(mask & roi_mask) + + +# ---------- auto_near_roi 找不到 → 空掩膜与显式原因 ---------- + + +def test_auto_returns_unavailable_when_background_all_black() -> None: + """auto:ROI 外全黑时不伪造背景圆,也不阻断病灶拟合。""" + ref = _ref_cfg(mode="auto_near_roi") + frame = _black_frame_with_bright_roi() + + mask, meta = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + assert not mask.any() + assert meta["available"] is False + assert meta["failure_reason"] == "no_valid_candidate" + + +def test_auto_returns_unavailable_when_background_all_green() -> None: + """auto:ROI 外全绿时返回空掩膜,不返回失真位置。""" + ref = _ref_cfg(mode="auto_near_roi") + frame = _green_background_frame() + + mask, meta = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + assert not mask.any() + assert meta["available"] is False + + +# ---------- 补强:精确键集 + 多帧 ROI 并集禁区 ---------- + + +def test_manual_metadata_exact_key_set() -> None: + """manual 分支 metadata 键集须精确(无缺键、无误增键)。""" + ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=18) + _, meta = select_reference_region( + _bright_background_frame(), + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + assert set(meta.keys()) == { + "enabled", + "mode", + "shape", + "center_xy", + "radius_px", + "area_px", + } + + +def test_auto_metadata_exact_key_set() -> None: + """auto 分支 metadata 键集须精确,含 valid_fraction_first_frame。""" + ref = _ref_cfg(mode="auto_near_roi") + _, meta = select_reference_region( + _bright_background_frame(), + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + assert set(meta.keys()) == { + "enabled", + "mode", + "shape", + "center_xy", + "radius_px", + "area_px", + "valid_fraction_first_frame", + } + + +def test_auto_avoids_doctor_purple_contour() -> None: + """auto:干净背景的判据要把医生画的紫色一起排掉,选中的圆不许压在紫色上。 + + 紫色轮廓是医生画上去的线,TIC 把这些像素从掩膜里扣掉——背景圆压在它上面的话, + ``valid_background_circle_px`` 会被悄悄削掉一块,而没有任何一处会说出来。紫色的灰度 + 不低(``nonblack_threshold`` 拦不住它),所以只排绿的判据会把它当成干净背景。 + + 先在全亮画面上选一次,把选中的那个圆整个涂成紫色再选第二次:``min_valid_fraction`` + 设成 1.0,于是只要圆里沾上一个紫色像素就出局,第二次必须换个位置。 + """ + ref = _ref_cfg(mode="auto_near_roi", min_valid_fraction=1.0) + frame = _bright_background_frame() + + first_mask, _ = select_reference_region( + frame, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + painted = frame.copy() + painted[first_mask] = _PURPLE_BGR + + second_mask, _ = select_reference_region( + painted, + _source_polygons(), + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + purple = np.all(painted == np.asarray(_PURPLE_BGR, dtype=np.uint8), axis=2) + assert second_mask.any(), "涂了一块紫色之后连一个合规位置都找不到了" + assert not np.any(second_mask & purple), "选中的背景圆压在紫色轮廓上" + + +def test_auto_keepout_uses_multiframe_roi_union() -> None: + """auto 禁区取逐帧 ROI 并集:选中圆须避开所有帧 ROI 的并集(非仅首帧)。""" + ref = _ref_cfg(mode="auto_near_roi") + # 两帧不同位置的 ROI:首帧右上、次帧偏左下,构成更大的并集禁区 + roi_a = _ROI_POLYGON + roi_b = np.array([[60.0, 90.0], [120.0, 90.0], [120.0, 140.0], [60.0, 140.0]], dtype=np.float32) + polys = np.array([roi_a, roi_b]) + + mask, _ = select_reference_region( + _bright_background_frame(), + polys, + _CONFIG.prompt_source, + _CONFIG.pan_source, + _tic_cfg(ref), + ) + + union = polygon_to_mask(roi_a, _H, _W) | polygon_to_mask(roi_b, _H, _W) + assert not np.any(mask & union), "参考圆须避开逐帧 ROI 并集,而非仅首帧 ROI" diff --git a/tests/test_ceus_tic.py b/tests/test_ceus_tic.py new file mode 100644 index 0000000..1009723 --- /dev/null +++ b/tests/test_ceus_tic.py @@ -0,0 +1,607 @@ +"""TIC 四曲线采样与基线(tic)单元测试(照搬旧项目 test_ceus_tic.py)。 + +旧标题:B9 TIC 四曲线 + 基线识别 + 平滑(tic.py)单元测试。 + +TIC(time-intensity curve,时间-强度曲线)刻画病灶亮度随时间变化。本模块在 source +视图上逐帧采四条灰度均值曲线——病灶整体、去掉暗区、紫色轮廓圈的邻近正常胰腺实质 +(``ROI_pan``)、程序自动挑的背景圆——每条各自识别增强前基线、扣掉基线均值再平滑。 +测试用真实 OpenCV 像素管线(非 mock)合成可控帧序列,钉死 ``tic.py`` 的真实行为: + +- 掩膜构成:``valid_roi_all = roi & ~(绿 ∪ 紫)``、``valid_roi_perf = valid_roi_all & + ~dark_union``、``valid_pan = 紫轮廓填充 & ~(绿 ∪ 紫)``、 + ``valid_background_circle = 背景圆 & ~(绿 ∪ 紫)``; +- ``mean_or_nan`` 空掩膜返回 NaN(不报错); +- 平滑链顺序 median→triangular,默认窗 5/15/1(以代码为准非 README); +- 基线识别:候选窗、稳定性缩窗、窗太短即无效(``mean`` / ``sd`` 为 ``None``); +- 撤掉合成零点之后,涨得最快的时刻落在真实陡升段而不是视频开头; +- 紫色检不到时 ``ROI_pan`` 整条 NaN,**不回退背景圆**; +- ``compute_tic_for_video`` 行集列名齐全,且本轮定量参数为 None。 +""" + +import math + +import numpy as np +import pytest + +from cadx.ceus import ( + CeusConfig, + PanSourceConfig, + TicAnalysisConfig, + compute_tic_for_video, + detect_baseline, + mean_or_nan, +) +from cadx.ceus import _tic as tic_module # pyright: ignore[reportPrivateUsage] +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) +_TIC_NO_DUPLICATES = _CONFIG.tic_analysis.model_copy( + update={ + "fitting": _CONFIG.tic_analysis.fitting.model_copy( + update={"detect_duplicate_frames": False} + ) + } +) + + +# ---------- 合成样本 ---------- + +_H, _W = 80, 120 +# ROI:画面中央偏右的方块(左下留干净背景给背景圆 / 掩膜对照)。 +_ROI_POLYGON = np.array( + [[60.0, 10.0], [110.0, 10.0], [110.0, 50.0], [60.0, 50.0]], dtype=np.float32 +) +# 暗区:ROI 内部一小块(用于验证 roi_perf = valid_roi_all & ~dark)。 +_DARK_POLYGON = np.array([[70.0, 20.0], [90.0, 20.0], [90.0, 40.0], [70.0, 40.0]], dtype=np.float32) +# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。 +_PURPLE_BGR = (255, 0, 212) + + +def _frame(roi_gray: int, dark_gray: int, ref_gray: int) -> np.ndarray: + """合成一帧 source BGR:ROI 区灰度 roi_gray、其内暗块 dark_gray、左下背景块 ref_gray。 + + 其余背景为 0(黑),避免被背景圆误采。灰度对 BGR 三通道相等填充,cvtColor 后即该灰度值。 + """ + frame = np.zeros((_H, _W, 3), dtype=np.uint8) + frame[10:50, 60:110] = roi_gray + frame[20:40, 70:90] = dark_gray + frame[10:40, 5:35] = ref_gray # 左下背景块 + return frame + + +def _background_circle_mask() -> np.ndarray: + """背景圆掩膜(左下背景块的子区域),与 _frame 的 ref 区重叠。""" + mask = np.zeros((_H, _W), dtype=bool) + mask[15:35, 10:30] = True + return mask + + +def _build_inputs( + frame_count: int = 3, +) -> tuple[list[np.ndarray], list[np.ndarray], list[list[np.ndarray]], np.ndarray]: + """构造逐帧 source 帧 / ROI 多边形 / 逐帧暗区多边形列表 + 背景圆 mask。 + + 暗区入参对齐 B7 ``extract_dark_region_polygons`` 输出(``list[list[np.ndarray]]``: + 外层帧对齐、内层每帧 0~N 个暗区多边形),此处每帧给单个暗块 ``[[_DARK_POLYGON]]``。 + 这批帧里**一个紫色像素都没有**,所以 ``ROI_pan`` 整条无效——这正是「检不到紫色 + 不回退背景圆」那条测试要的场景。 + """ + frames = [_frame(roi_gray=120, dark_gray=20, ref_gray=60) for _ in range(frame_count)] + roi_polygons = [_ROI_POLYGON for _ in range(frame_count)] + dark_polygons_per_frame = [[_DARK_POLYGON] for _ in range(frame_count)] + return frames, roi_polygons, dark_polygons_per_frame, _background_circle_mask() + + +def _run( + frames: list[np.ndarray], + roi_polygons: list[np.ndarray], + dark_polygons_per_frame: list[list[np.ndarray]], + background_circle_mask: np.ndarray, + *, + fps: float = 2.0, + pan_cfg: PanSourceConfig | None = None, + tic_cfg: TicAnalysisConfig | None = None, +) -> tic_module.TicResult: + """按默认配置跑一次 ``compute_tic_for_video``(省掉各测试里重复的样板)。""" + return compute_tic_for_video( + source_frames=frames, + roi_polygons=roi_polygons, + dark_polygons_per_frame=dark_polygons_per_frame, + reference_mask=background_circle_mask, + fps=fps, + prompt_cfg=_CONFIG.prompt_source, + pan_cfg=pan_cfg or _CONFIG.pan_source, + tic_cfg=tic_cfg or _TIC_NO_DUPLICATES, + frame_times_s=None, + ) + + +def _column(result: tic_module.TicResult, key: str) -> np.ndarray: + """取一列逐帧值。""" + return np.asarray([row[key] for row in result.rows], dtype=np.float32) + + +# ---------- mean_or_nan:NaN 语义 ---------- + + +def test_mean_or_nan_empty_mask_returns_nan() -> None: + """空掩膜(无 True 像素)返回 NaN,而非报错或 0。""" + gray = np.full((_H, _W), 120, dtype=np.uint8) + empty = np.zeros((_H, _W), dtype=bool) + result = mean_or_nan(gray, empty) + assert math.isnan(result) + + +def test_mean_or_nan_returns_masked_mean() -> None: + """非空掩膜返回该掩膜内灰度均值。""" + gray = np.zeros((_H, _W), dtype=np.uint8) + gray[0:10, 0:10] = 200 + mask = np.zeros((_H, _W), dtype=bool) + mask[0:10, 0:10] = True + assert mean_or_nan(gray, mask) == pytest.approx(200.0) + + +# ---------- detect_baseline:纯函数层 ---------- + + +def test_detect_baseline_flat_prefix_gives_mean_and_sd() -> None: + """开头两秒平坦时,基线窗取满首选时长,``B`` 与 ``σ0`` 从这一段算出来。""" + values = np.concatenate( + [ + np.full(20, 40.0, dtype=np.float32), + np.linspace(40, 200, 40, dtype=np.float32), + ] + ) + baseline = detect_baseline( + values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None + ) + assert baseline.valid is True + assert baseline.frame_start == 0 + assert baseline.frame_end == 20 + assert baseline.mean == pytest.approx(40.0) + assert baseline.sd == pytest.approx(0.0) + + +def test_detect_baseline_ignores_nan_samples() -> None: + """NaN 帧不算基线采样:既不进均值,也不被当成 0 把基线拉低。""" + values = np.full(20, 40.0, dtype=np.float32) + values[3] = np.nan + values[11] = np.nan + baseline = detect_baseline( + values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None + ) + assert baseline.valid is True + assert baseline.mean == pytest.approx(40.0) + + +def test_detect_baseline_all_nan_is_invalid() -> None: + """整条都是 NaN 时拿不到基线(不是拿到一个由零个样本算出来的数)。""" + baseline = detect_baseline( + np.full(30, np.nan, dtype=np.float32), + np.arange(30) / 10.0, + _CONFIG.tic_analysis.baseline, + None, + ) + assert baseline.valid is False + assert baseline.mean is None + assert baseline.sd is None + assert baseline.frame_start == -1 + assert baseline.frame_end == -1 + + +def test_detect_baseline_rejects_non_1d() -> None: + """二维输入直接报错,不静默摊平。""" + with pytest.raises(ValueError): + detect_baseline( + np.zeros((3, 4), dtype=np.float32), + np.arange(12).reshape(3, 4), + _CONFIG.tic_analysis.baseline, + None, + ) + + +def test_detect_baseline_rejects_non_increasing_timestamps() -> None: + """时间戳不严格递增直接报错。""" + with pytest.raises(ValueError): + detect_baseline( + np.zeros(30, dtype=np.float32), np.zeros(30), _CONFIG.tic_analysis.baseline, None + ) + + +# ---------- 撤掉合成零点之后:最大流入斜率不落视频开头 ---------- + + +# ---------- 基线窗不足最短时长:两种不足 ---------- + + +def _assert_baseline_unusable(result: tic_module.TicResult, expected_reason: str) -> None: + """观察基线拿不到时保留截断标志,失败拟合不回填 processed。""" + baseline = result.baselines["roi_all"] + assert baseline.valid is False + assert baseline.mean is None + assert baseline.sd is None + assert result.early_truncation_flag is True + + assert result.fits["roi_all"].failure_reason == expected_reason + assert np.isnan(_column(result, "roi_all_processed")).all() + + +def test_baseline_invalid_when_video_shorter_than_minimum() -> None: + """视频太短(0.8 秒)凑不出 1 秒基线:不合成、不外推,标 early_truncation。""" + frame_count = 8 + frames = [_frame(roi_gray=70, dark_gray=70, ref_gray=60) for _ in range(frame_count)] + result = _run( + frames, + [_ROI_POLYGON for _ in range(frame_count)], + [[] for _ in range(frame_count)], + _background_circle_mask(), + fps=10.0, + ) + _assert_baseline_unusable(result, "insufficient_valid_samples") + + +def test_baseline_invalid_when_enhancement_already_rising_at_frame_zero() -> None: + """视频够长但录晚了、开头就在涨:稳定性检查把窗缩到不足 1 秒,同样判无效。""" + frame_count = 60 + frames = [ + _frame(roi_gray=40 + 2 * index, dark_gray=40 + 2 * index, ref_gray=60) + for index in range(frame_count) + ] + result = _run( + frames, + [_ROI_POLYGON for _ in range(frame_count)], + [[] for _ in range(frame_count)], + _background_circle_mask(), + fps=10.0, + ) + _assert_baseline_unusable(result, "credible_peak_not_found") + + +# ---------- ROI_pan:检不到紫色不回退背景圆 ---------- + + +def test_pan_curve_not_backfilled_from_background_circle() -> None: + """一个紫色像素都没有时,``ROI_pan`` 整条 NaN,而背景圆那条照常算出来。 + + 只断言「pan 无效」不够——一个「检不到就复制背景圆」的实现照样能让前三条过。 + 第四条(两条曲线不逐点相等)才是要害。 + """ + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs() + result = _run(frames, roi_polygons, dark_polygons_per_frame, background) + + assert result.reference_valid is False + + pan_raw = _column(result, "roi_pan_raw") + background_raw = _column(result, "background_circle_raw") + assert np.isnan(pan_raw).all() + assert result.baselines["roi_pan"].valid is False + assert result.baselines["roi_pan"].mean is None + assert np.isfinite(background_raw).all() + assert not np.array_equal(pan_raw, background_raw, equal_nan=True) + + +def _frame_with_purple_contour(interior_gray: int) -> np.ndarray: + """在基础帧的左下角画一圈紫色轮廓,圈内填 ``interior_gray``。 + + 轮廓画成空心矩形(医生画的是一条线,不是一块面),所以填充后的参照区内部是灰度、 + 只有那圈线是紫色。轮廓外面也铺同一片灰度:``extract_color_prompt`` 的形态学膨胀会 + 让填充区比画上去的那圈线略大一点,外面若是纯黑就会把均值拉低——真实画面上轮廓外 + 也是组织,这里照着来。 + """ + frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60) + frame[46:80, 1:50] = interior_gray + frame[52:55, 5:46] = _PURPLE_BGR + frame[74:77, 5:46] = _PURPLE_BGR + frame[52:77, 5:8] = _PURPLE_BGR + frame[52:77, 43:46] = _PURPLE_BGR + return frame + + +def test_pan_curve_reads_purple_contour_interior() -> None: + """画面上有紫色轮廓时,``ROI_pan`` 采的是轮廓**内部**的灰度,轮廓线本身被剔掉。""" + interior_gray = 90 + frame_count = 3 + frames = [_frame_with_purple_contour(interior_gray) for _ in range(frame_count)] + result = _run( + frames, + [_ROI_POLYGON for _ in range(frame_count)], + [[_DARK_POLYGON] for _ in range(frame_count)], + _background_circle_mask(), + pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}), + ) + assert result.reference_valid is True + pan_raw = _column(result, "roi_pan_raw") + assert np.isfinite(pan_raw).all() + # 轮廓线(紫)被排除色掩膜剔掉,剩下的全是内部那块灰度。 + np.testing.assert_allclose(pan_raw, interior_gray, atol=1.0) + assert result.rows[0]["valid_pan_px"] > 0 + assert result.rows[0]["pan_area_px"] > result.rows[0]["valid_pan_px"] + + +def test_reference_valid_counts_sampled_frames_not_purple_area() -> None: + """一整块实心紫色:紫色本身有面积,但排除色把它整块吞掉,一个像素都没采到。 + + 界面上的实心紫色标记就长这样:``extract_color_prompt`` 填充出来的面积过得了 + ``min_area``,而排除掩膜把这块紫色连同外扩的一圈整个盖住,``valid_pan`` 是空的。 + 检出率若按填充面积数,台账上会写着「参照区没问题」,底下压着一条整段 NaN 的曲线, + 而库上那条「``reference_valid`` 为假时 ``_pan`` 基线必须记 null」的 CHECK 只在 + ``reference_valid`` 为假时才管,此时一声不响。 + + ``prompt_exclude_dilate`` 调到 5:检测那一路的形态学会把填充区外扩两像素,默认的 + 3 像素核只外扩一像素,边上会剩一圈采得到的像素——那不是本条要造的场景。 + """ + frame_count = 3 + frames: list[np.ndarray] = [] + for _ in range(frame_count): + frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60) + frame[50:78, 5:45] = _PURPLE_BGR # 实心紫块,不是一圈线 + frames.append(frame) + result = _run( + frames, + [_ROI_POLYGON for _ in range(frame_count)], + [[_DARK_POLYGON] for _ in range(frame_count)], + _background_circle_mask(), + pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}), + tic_cfg=_CONFIG.tic_analysis.model_copy(update={"prompt_exclude_dilate": 5}), + ) + + assert result.rows[0]["pan_area_px"] > 0, "紫色轮廓本身确实有面积——前提没造对" + assert all(row["valid_pan_px"] == 0 for row in result.rows) + assert np.isnan(_column(result, "roi_pan_raw")).all() + assert result.reference_valid is False + + +def test_early_truncation_ignores_background_circle_baseline() -> None: + """三条规范曲线基线都好、只有背景圆那条被缩短:质控位不许置起来。 + + 背景圆是我们自己的整机增益漂移曲线,不喂任何规范字段(design/0030 决策四第三条), + 而 ``early_truncation_flag`` 是规范 §14 的质控位。自动挑中的圆落在一块两秒内就开始 + 增强的组织上时,它的基线窗会被缩到不足最短时长——那和「这段视频被截短了」是两回事。 + """ + frame_count = 6 + frames: list[np.ndarray] = [] + for index in range(frame_count): + frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60 + 30 * index) + # 紫色轮廓(空心矩形)画在左下,和背景圆那块不重叠。 + frame[46:80, 1:50] = 90 + frame[52:55, 5:46] = _PURPLE_BGR + frame[74:77, 5:46] = _PURPLE_BGR + frame[52:77, 5:8] = _PURPLE_BGR + frame[52:77, 43:46] = _PURPLE_BGR + frames.append(frame) + result = _run( + frames, + [_ROI_POLYGON for _ in range(frame_count)], + [[_DARK_POLYGON] for _ in range(frame_count)], + _background_circle_mask(), + fps=2.0, + pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}), + ) + + for name in ("roi_all", "roi_perf", "roi_pan"): + assert result.baselines[name].valid is True, f"{name} 的基线该是好的——前提没造对" + assert result.baselines["background_circle"].valid is False, ( + "背景圆那条该拿不到基线——前提没造对" + ) + assert result.early_truncation_flag is False + + +def test_purple_pixels_are_excluded_from_roi_statistics() -> None: + """紫色像素落进 ROI 时同样被剔除(排除色 = 绿 ∪ 紫,不再只排绿)。""" + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1) + baseline_result = _run(frames, roi_polygons, dark_polygons_per_frame, background) + baseline_valid_px = baseline_result.rows[0]["valid_roi_all_px"] + + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1) + frames[0][12:18, 65:105] = _PURPLE_BGR + result = _run(frames, roi_polygons, dark_polygons_per_frame, background) + assert result.rows[0]["valid_roi_all_px"] < baseline_valid_px + + +# ---------- 删掉的名字不许回来 ---------- + + +def test_synthetic_zero_timepoint_names_are_gone() -> None: + """合成零点那一套的名字全部不存在,行集里也不再有 ``frame_index=-1`` 的合成行。 + + 守的是 design/0030 决策二那次删除。理由写在 ``tic.TicResult`` 的 docstring 上: + 人造点到首个真实帧那一跳是全曲线最大斜率,最大流入斜率的时刻会被永远钉在视频开头。 + """ + assert not hasattr(tic_module, "prepend_zero_timepoint") + assert not hasattr(tic_module, "_zero_timepoint_row") + assert "prepend_zero_timepoint" not in tic_module.__all__ + + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs() + result = _run(frames, roi_polygons, dark_polygons_per_frame, background) + assert len(result.rows) == len(frames) + for index, row in enumerate(result.rows): + assert row["frame_index"] == index + assert "is_zero_timepoint" not in row + + +# ---------- compute_tic_for_video:掩膜构成 + 列名 + 参数 None ---------- + + +def test_compute_tic_curve_mask_composition() -> None: + """掩膜构成正确:ROI 区灰度 120、去暗区后仍 120(暗块被剔)、背景圆 60。 + + 暗块灰度 20 远低于 ROI 主体 120,若 roi_perf 未剔暗块,其均值会被拉低; + 剔除后 roi_perf 应等于 ROI 主体灰度 120。 + """ + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs() + sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0] + # roi_all_raw 含暗块(120 主体混 20 暗块)→ 被拉低到 120 与 20 之间。 + assert 20.0 < sample["roi_all_raw"] < 120.0 + # 暗块(20)被剔后 roi_perf 应接近纯 ROI 主体 120(栅格边界残留少量暗像素,放宽), + # 且严格高于含暗的 roi_all(剔暗确实抬升了均值)。 + assert sample["roi_perf_raw"] == pytest.approx(120.0, abs=8.0) + assert sample["roi_perf_raw"] > sample["roi_all_raw"] + assert sample["background_circle_raw"] == pytest.approx(60.0, abs=1.0) + # 去暗后有效像素数 < 含暗的 valid_roi_all 像素数。 + assert sample["valid_roi_perf_px"] < sample["valid_roi_all_px"] + + +def test_compute_tic_prompt_excluded_from_masks() -> None: + """绿框像素从掩膜中剔除:在 ROI 内画绿条后,valid_roi_all 像素数应减少。""" + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1) + # 在 ROI 内画一条绿色(BGR 绿,HSV 落在默认阈内)。 + frames[0][12:18, 65:105] = (0, 255, 0) + sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0] + # roi 区栅格面积固定,但剔绿后 valid_roi_all 有效像素严格少于 roi_area。 + assert sample["valid_roi_all_px"] < sample["roi_area_px"] + + +def test_compute_tic_row_column_names_complete() -> None: + """行集含四曲线 raw/candidate/fitted/processed 与样本质控列。""" + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs() + result = _run(frames, roi_polygons, dark_polygons_per_frame, background) + expected_columns = { + "frame_index", + "time_sec", + "duplicate_frame", + "roi_area_px", + "dark_area_px", + "pan_area_px", + "background_circle_area_px", + "valid_roi_all_px", + "valid_roi_perf_px", + "valid_pan_px", + "valid_background_circle_px", + "dark_component_count", + "perf_mask_stable", + } + for name in tic_module.CURVE_NAMES: + expected_columns |= { + f"{name}_raw", + f"{name}_candidate", + f"{name}_fitted", + f"{name}_processed", + } + for row in result.rows: + assert set(row.keys()) == expected_columns + + +def test_compute_tic_uses_explicit_presentation_timestamps() -> None: + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs() + times = [0.0, 0.11, 0.24] + result = compute_tic_for_video( + frames, + roi_polygons, + dark_polygons_per_frame, + background, + 10.0, + _CONFIG.prompt_source, + _CONFIG.pan_source, + _TIC_NO_DUPLICATES, + times, + ) + np.testing.assert_allclose(_column(result, "time_sec"), times) + + +def test_compute_tic_marks_consecutive_duplicate_roi_frames() -> None: + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs() + result = compute_tic_for_video( + frames, + roi_polygons, + dark_polygons_per_frame, + background, + 2.0, + _CONFIG.prompt_source, + _CONFIG.pan_source, + _CONFIG.tic_analysis, + None, + ) + assert [row["duplicate_frame"] for row in result.rows] == [False, True, True] + + +def test_compute_tic_parameters_none_this_round() -> None: + """本轮不算定量参数,``parameters`` 为 None。""" + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs() + assert _run(frames, roi_polygons, dark_polygons_per_frame, background).parameters is None + + +def test_compute_tic_background_circle_excludes_green_pixels() -> None: + """证伪 ``valid_background = 背景圆 & ~exclude``:圆内画绿后剔绿,统计须随之变化。 + + 在背景圆内填入一片绿色(HSV 落默认阈内)。若掩膜未剔绿,绿像素会计入 + ``valid_background_circle_px`` 且把 ``background_circle_raw`` 拉向绿灰度; + 剔绿后两者均应改变:有效像素数严格少于圆面积,均值贴近纯背景灰度而非被绿污染。 + """ + frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1) + # 背景圆掩膜为 [15:35, 10:30],背景块 ref_gray=60;在其内画一片绿。 + frames[0][16:24, 12:28] = (0, 255, 0) + sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0] + assert sample["valid_background_circle_px"] < sample["background_circle_area_px"] + # 均值未被绿(cvtColor 后灰度约 150)污染,仍贴近背景 60。 + assert sample["background_circle_raw"] == pytest.approx(60.0, abs=2.0) + + +def test_compute_tic_dark_region_clipped_to_roi() -> None: + """证伪 ``dark_union &= valid_roi_all``:越出 ROI 的暗区多边形,越界部分不计入统计。 + + 给某帧一个跨越 ROI 右边界(x=110)外延的暗区多边形。若未收口到 ROI 内, + ``dark_area_px`` 会含 ROI 外像素;收口后暗区面积应等于该暗块与 ROI 的交集。 + """ + frames, roi_polygons, _, background = _build_inputs(frame_count=1) + # 暗块横跨 ROI 右界 x∈[100,140](ROI 右界 110),y∈[20,40] 落在 ROI 内 y 范围。 + out_of_roi_dark = np.array( + [[100.0, 20.0], [140.0, 20.0], [140.0, 40.0], [100.0, 40.0]], dtype=np.float32 + ) + sample = _run(frames, roi_polygons, [[out_of_roi_dark]], background).rows[0] + # 暗块多边形宽 40,但仅 x∈[100,110] 落在 ROI 内(宽 10);收口后面积须 < 多边形整块。 + full_dark_area = 40 * 20 # 多边形自身栅格面积上界 + assert 0 < sample["dark_area_px"] < full_dark_area + # 收口后暗区不超过有效 ROI 像素数(不会从 ROI 外多剔像素)。 + assert sample["dark_area_px"] <= sample["valid_roi_all_px"] + # roi_perf = valid_roi_all - 收口暗区,二者之差恰为收口后暗区面积。 + assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"] + + +def test_compute_tic_dark_polygon_hole_not_filled_back() -> None: + """证伪 even-odd 挖洞:暗区甜甜圈(外暗环 + 内亮洞)的洞不被计回暗区。 + + 暗区多边形列表为 ``[外多边形, 洞多边形]``(B7 preserve_holes 扁平输出语义)。 + 若按「逐多边形栅格化再 OR」,洞会被当作独立暗块填回 → ``dark_area_px`` 含洞、 + ``roi_perf`` 偏小;正确的 even-odd 一次填充应把洞挖空。 + """ + frames, roi_polygons, _, background = _build_inputs(frame_count=1) + # 外暗框 [65,15]-[105,45](全落 ROI x∈[60,110] / y∈[10,50] 内),内亮洞 [80,25]-[95,38]。 + outer_dark = np.array( + [[65.0, 15.0], [105.0, 15.0], [105.0, 45.0], [65.0, 45.0]], dtype=np.float32 + ) + inner_hole = np.array( + [[80.0, 25.0], [95.0, 25.0], [95.0, 38.0], [80.0, 38.0]], dtype=np.float32 + ) + sample = _run(frames, roi_polygons, [[outer_dark, inner_hole]], background).rows[0] + outer_area = 40 * 30 # 外框栅格面积 1200 + hole_area = 15 * 13 # 洞栅格面积 195 + # 暗区不含洞:≈ 外框 − 洞(栅格边界放宽),严格小于把洞填回的「外框整块」。 + assert sample["dark_area_px"] == pytest.approx(outer_area - hole_area, abs=120) + assert sample["dark_area_px"] < outer_area + # 洞内 ROI 像素仍算有效 ROI:roi_perf = valid_roi_all − 暗区(不含洞)。 + assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"] + # 洞内像素确实回到 roi_perf:去暗后剩余像素 > 仅外框实心被剔时的剩余。 + assert sample["valid_roi_perf_px"] > sample["valid_roi_all_px"] - outer_area + + +def test_compute_tic_dark_component_count_self_consistent() -> None: + """暗块计数与暗区面积自洽:有暗块时计数=多边形数;面积为 0 时计数强制归 0。 + + 第 0 帧给两个 ROI 内暗块 → count=2;第 1 帧暗块完全落在 ROI 外(收口后面积 0) + → count 必须归 0(修复 area=0 却 count>0 的不自洽)。 + """ + frames, roi_polygons, _, background = _build_inputs(frame_count=2) + dark_a = np.array([[65.0, 15.0], [78.0, 15.0], [78.0, 30.0], [65.0, 30.0]], dtype=np.float32) + dark_b = np.array([[82.0, 32.0], [95.0, 32.0], [95.0, 45.0], [82.0, 45.0]], dtype=np.float32) + # 完全落在 ROI(x∈[60,110])右侧之外,收口后面积为 0。 + fully_outside = np.array( + [[115.0, 15.0], [119.0, 15.0], [119.0, 30.0], [115.0, 30.0]], dtype=np.float32 + ) + rows = _run(frames, roi_polygons, [[dark_a, dark_b], [fully_outside]], background).rows + # 第 0 帧:两个 ROI 内暗块 → 面积 > 0、计数 = 2。 + assert rows[0]["dark_area_px"] > 0 + assert rows[0]["dark_component_count"] == 2 + # 第 1 帧:暗块全在 ROI 外 → 收口面积 0、计数强制归 0(自洽)。 + assert rows[1]["dark_area_px"] == 0 + assert rows[1]["dark_component_count"] == 0 diff --git a/tests/test_ceus_tic_curves.py b/tests/test_ceus_tic_curves.py new file mode 100644 index 0000000..1ec4dc2 --- /dev/null +++ b/tests/test_ceus_tic_curves.py @@ -0,0 +1,420 @@ +"""TIC 曲线 npz 打包 / 解包(tic_curves)单元测试(照搬旧项目 test_ceus_tic_curves.py)。 + +钉死曲线工件的单一 schema:完整往返还原全部键、存档内各键 dtype / shape 精确、 +NaN 原样往返(**不许被填成 0**——那会把「这一帧没采到」变成「这一帧亮度是 0」)、 +基线无效时三个基线键的编码(NaN / NaN / (-1,-1) / 0),以及防御:坏字节解包 raise、 +长度不一致 pack raise、禁 pickle(含 object pickle 的恶意 npz 解包被拒)。 +另有一条从 ``TicResult`` 构造的往返,确认 ``from_tic_result`` 认得行键。 +结构照 ``tests/unit/test_ceus_trajectory.py``。 +""" + +import io +import pickle +from typing import Any + +import numpy as np +import pytest + +from cadx.ceus import ( + CURVE_NAMES, + CURVES_SCHEMA_VERSION, + CeusConfig, + TicCurves, + TicCurveSeries, + compute_tic_for_video, + from_tic_result, + pack_tic_curves, + unpack_tic_curves, +) +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为读 config/ceus.yaml 的取值。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) + + +_T = 5 + + +# Phase 1: 构造一个真实形态的 TicCurves(合成数值) + + +def _series(offset: float, *, valid: bool) -> TicCurveSeries: + """一条曲线:中间插一个 NaN(该帧没采到),基线按 valid 给或不给。""" + raw = np.asarray([10.0, 20.0, np.nan, 40.0, 50.0], dtype=np.float32) + offset + processed = np.asarray([11.0, 21.0, 31.0, 41.0, 51.0], dtype=np.float32) + offset + common: dict[str, Any] = { + "candidate": processed.copy(), + "fitted": processed.copy(), + "processed": processed.copy() if valid else np.full(_T, np.nan, np.float32), + "valid_sample_mask": np.isfinite(raw), + "duplicate_sample_mask": np.asarray([False, False, True, False, False]), + "fit_success": valid, + "fit_failure_reason": "" if valid else "baseline_invalid", + "curve_model": "lognormal", + "smoothing_method": "", + "baseline_source": "observed" if valid else "unavailable", + "fit_area": 123.0 if valid else float("nan"), + "fit_mu": 2.0 if valid else float("nan"), + "fit_sigma": 0.5 if valid else float("nan"), + "fit_arrival_offset_s": 1.0 if valid else float("nan"), + "fit_baseline": 14.5 + offset if valid else float("nan"), + "fit_r2": 0.9 if valid else float("nan"), + "fit_nrmse": 0.1 if valid else float("nan"), + "residual_noise_ratio": 1.2 if valid else float("nan"), + "fit_interval_s": (0.0, 4.0) if valid else (float("nan"), float("nan")), + "multi_peak_flag": False, + "anchor_times_s": (1.0, 2.0, 3.0, 4.0) if valid else (float("nan"),) * 4, + } + if valid: + return TicCurveSeries( + raw=raw, + baseline_mean=15.0 + offset, + baseline_sd=2.5, + baseline_frames=(0, 2), + baseline_valid=True, + **common, + ) + return TicCurveSeries( + raw=raw, + baseline_mean=float("nan"), + baseline_sd=float("nan"), + baseline_frames=(-1, -1), + baseline_valid=False, + **common, + ) + + +def _make_curves(*, injection_offset_s: float = 3.5) -> TicCurves: + counts = { + key: np.arange(_T, dtype=np.int32) + index + for index, key in enumerate( + ( + "roi_area_px", + "dark_area_px", + "pan_area_px", + "background_circle_area_px", + "valid_roi_all_px", + "valid_roi_perf_px", + "valid_pan_px", + "valid_background_circle_px", + "dark_component_count", + ) + ) + } + frame_indices = np.arange(_T, dtype=np.int32) + return TicCurves( + frame_indices=frame_indices, + time_video_s=(frame_indices / np.float32(10.0)).astype(np.float32), + injection_offset_s=injection_offset_s, + time_origin="injection", + fps=10.0, + config_version="test-v1", + algorithm_version="lognormal-v2", + curves_schema_version=CURVES_SCHEMA_VERSION, + curves={ + # roi_pan 那条给成基线无效,覆盖「紫色没检出」那一路的编码。 + name: _series(float(index) * 100.0, valid=(name != "roi_pan")) + for index, name in enumerate(CURVE_NAMES) + }, + perf_mask_stable=np.ones(_T, dtype=bool), + reference_valid=False, + early_truncation_flag=True, + **counts, + ) + + +# Phase 2: 完整往返 —— 全部键还原 + + +def test_roundtrip_preserves_all_keys() -> None: + curves = _make_curves() + restored = unpack_tic_curves(pack_tic_curves(curves)) + + np.testing.assert_array_equal(restored.frame_indices, curves.frame_indices) + np.testing.assert_allclose(restored.time_video_s, curves.time_video_s) + assert restored.injection_offset_s == pytest.approx(curves.injection_offset_s) + assert restored.time_origin == curves.time_origin + assert restored.fps == pytest.approx(curves.fps) + assert restored.curves_schema_version == curves.curves_schema_version + assert restored.reference_valid is curves.reference_valid + assert restored.early_truncation_flag is curves.early_truncation_flag + np.testing.assert_array_equal(restored.perf_mask_stable, curves.perf_mask_stable) + + for key in ( + "roi_area_px", + "dark_area_px", + "pan_area_px", + "background_circle_area_px", + "valid_roi_all_px", + "valid_roi_perf_px", + "valid_pan_px", + "valid_background_circle_px", + "dark_component_count", + ): + np.testing.assert_array_equal(getattr(restored, key), getattr(curves, key)) + + assert set(restored.curves) == set(CURVE_NAMES) + for name in CURVE_NAMES: + original = curves.curves[name] + back = restored.curves[name] + np.testing.assert_allclose(back.raw, original.raw, equal_nan=True) + np.testing.assert_allclose(back.candidate, original.candidate, equal_nan=True) + np.testing.assert_allclose(back.fitted, original.fitted, equal_nan=True) + np.testing.assert_allclose(back.processed, original.processed, equal_nan=True) + np.testing.assert_array_equal(back.valid_sample_mask, original.valid_sample_mask) + assert back.baseline_frames == original.baseline_frames + assert back.baseline_valid is original.baseline_valid + assert back.baseline_source == original.baseline_source + assert back.fit_baseline == pytest.approx(original.fit_baseline, nan_ok=True) + + +def test_roundtrip_keeps_nan_as_nan() -> None: + """NaN 原样往返:既不被填成 0,也不被插值掉。""" + restored = unpack_tic_curves(pack_tic_curves(_make_curves())) + for name in CURVE_NAMES: + assert np.isnan(restored.curves[name].raw[2]) + + +def test_invalid_baseline_roundtrips_as_nan_and_minus_one() -> None: + """基线无效那条:均值 / 标准差是 NaN,帧区间 ``(-1, -1)``,valid 为 False。 + + ``0.0`` 是一个能一路算下去、把下游全部算错的合法数值;NaN 不是。 + """ + restored = unpack_tic_curves(pack_tic_curves(_make_curves())) + pan = restored.curves["roi_pan"] + assert np.isnan(pan.baseline_mean) + assert np.isnan(pan.baseline_sd) + assert pan.baseline_frames == (-1, -1) + assert pan.baseline_valid is False + + +# Phase 3: 存档层 schema —— 直接在 npz 原始存档上断言键名 / dtype / shape + + +def test_archive_schema_keys_dtypes_shapes() -> None: + """打开 pack 产出的原始 npz 存档,逐一断言键集 / 存储 dtype / shape。 + + 与对象层往返互补:往返只验 ``unpack(pack(x)) == x``,发现不了 pack 写入存档时的 + dtype / shape 漂移。 + """ + archive_bytes = pack_tic_curves(_make_curves()) + with np.load(io.BytesIO(archive_bytes), allow_pickle=False) as archive: + expected_keys = { + "frame_indices", + "time_video_s", + "injection_offset_s", + "time_origin", + "fps", + "config_version", + "algorithm_version", + "curves_schema_version", + "reference_valid", + "early_truncation_flag", + "perf_mask_stable", + "roi_area_px", + "dark_area_px", + "pan_area_px", + "background_circle_area_px", + "valid_roi_all_px", + "valid_roi_perf_px", + "valid_pan_px", + "valid_background_circle_px", + "dark_component_count", + } + for name in CURVE_NAMES: + expected_keys |= { + f"{name}_raw", + f"{name}_candidate", + f"{name}_fitted", + f"{name}_processed", + f"{name}_valid_sample_mask", + f"{name}_duplicate_sample_mask", + f"{name}_baseline_mean", + f"{name}_baseline_sd", + f"{name}_baseline_frames", + f"{name}_baseline_valid", + f"{name}_fit_success", + f"{name}_fit_failure_reason", + f"{name}_curve_model", + f"{name}_smoothing_method", + f"{name}_baseline_source", + f"{name}_fit_parameters", + f"{name}_fit_quality", + f"{name}_fit_interval_s", + f"{name}_multi_peak_flag", + f"{name}_anchor_times_s", + } + assert set(archive.files) == expected_keys + + assert archive["frame_indices"].dtype == np.int32 + assert archive["frame_indices"].shape == (_T,) + assert archive["time_video_s"].dtype == np.float32 + assert archive["time_video_s"].shape == (_T,) + assert archive["injection_offset_s"].dtype == np.float32 + assert archive["injection_offset_s"].shape == (1,) + assert archive["fps"].dtype == np.float32 + assert archive["fps"].shape == (1,) + assert archive["reference_valid"].dtype == np.int8 + assert archive["early_truncation_flag"].dtype == np.int8 + for key in ("roi_area_px", "pan_area_px", "dark_component_count"): + assert archive[key].dtype == np.int32 + assert archive[key].shape == (_T,) + for name in CURVE_NAMES: + assert archive[f"{name}_raw"].dtype == np.float32 + assert archive[f"{name}_raw"].shape == (_T,) + assert archive[f"{name}_candidate"].dtype == np.float32 + assert archive[f"{name}_fitted"].dtype == np.float32 + assert archive[f"{name}_processed"].dtype == np.float32 + assert archive[f"{name}_baseline_mean"].dtype == np.float32 + assert archive[f"{name}_baseline_mean"].shape == (1,) + assert archive[f"{name}_baseline_sd"].shape == (1,) + assert archive[f"{name}_baseline_frames"].dtype == np.int32 + assert archive[f"{name}_baseline_frames"].shape == (2,) + assert archive[f"{name}_baseline_valid"].dtype == np.int8 + assert archive[f"{name}_fit_parameters"].dtype == np.float64 + assert archive[f"{name}_fit_parameters"].shape == (5,) + + +# Phase 4: 不存的那三样确实不在文件里 + + +def test_derivable_quantities_are_not_stored() -> None: + """``S(t)``、``t_injection``、无灌注比例都不进文件(各自的理由见 TicCurves docstring)。""" + with np.load(io.BytesIO(pack_tic_curves(_make_curves())), allow_pickle=False) as a: + keys = set(a.files) + assert not {key for key in keys if "baseline_subtracted" in key} + assert "time_injection_s" not in keys + assert not {key for key in keys if "non_perfused" in key} + + +# Phase 5: 从 TicResult 构造 + + +def _tic_result(): + """跑一次真实的 ``compute_tic_for_video``(合成帧),拿一个 TicResult。""" + height, width, frame_count = 60, 80, 4 + roi_polygon = np.array( + [[30.0, 10.0], [70.0, 10.0], [70.0, 40.0], [30.0, 40.0]], dtype=np.float32 + ) + frames: list[np.ndarray] = [] + for _ in range(frame_count): + frame = np.zeros((height, width, 3), dtype=np.uint8) + frame[10:40, 30:70] = 100 + frame[10:30, 5:25] = 50 + frames.append(frame) + background = np.zeros((height, width), dtype=bool) + background[12:28, 8:22] = True + return compute_tic_for_video( + source_frames=frames, + roi_polygons=[roi_polygon for _ in range(frame_count)], + dark_polygons_per_frame=[[] for _ in range(frame_count)], + reference_mask=background, + fps=4.0, + prompt_cfg=_CONFIG.prompt_source, + pan_cfg=_CONFIG.pan_source, + tic_cfg=_CONFIG.tic_analysis, + frame_times_s=None, + ) + + +def test_from_tic_result_roundtrips() -> None: + """``from_tic_result`` 认得行键,产物能原样打包再解回来。""" + result = _tic_result() + curves = from_tic_result(result, fps=4.0, injection_offset_s=1.25, config_version="test-v1") + restored = unpack_tic_curves(pack_tic_curves(curves)) + + assert restored.time_origin == "injection" + assert restored.injection_offset_s == pytest.approx(1.25) + assert restored.frame_indices.tolist() == [0, 1, 2, 3] + np.testing.assert_allclose(restored.time_video_s, [0.0, 0.25, 0.5, 0.75], atol=1e-6) + assert set(restored.curves) == set(CURVE_NAMES) + assert restored.reference_valid is result.reference_valid + np.testing.assert_allclose( + restored.curves["roi_all"].raw, + [row["roi_all_raw"] for row in result.rows], + equal_nan=True, + ) + + +def test_from_tic_result_without_injection_offset_writes_nan_not_zero() -> None: + """推注偏移取不到时写 NaN、时间零点记「视频起点」——**不填 0**。 + + 填 0 等于断言「视频和推注同时开始」,而那是我们没有的信息(design/0030 决策三)。 + """ + curves = from_tic_result( + _tic_result(), fps=4.0, injection_offset_s=None, config_version="test-v1" + ) + restored = unpack_tic_curves(pack_tic_curves(curves)) + assert np.isnan(restored.injection_offset_s) + assert restored.time_origin == "video_start" + + +def test_from_tic_result_rejects_non_positive_fps() -> None: + with pytest.raises(ValueError): + from_tic_result( + _tic_result(), + fps=0.0, + injection_offset_s=None, + config_version="test-v1", + ) + + +# Phase 6: 防御 —— 坏字节 / 长度不一致 / 禁 pickle + + +def test_unpack_bad_bytes_raises() -> None: + with pytest.raises((ValueError, OSError)): + unpack_tic_curves(b"this-is-not-a-valid-npz-archive") + + +def test_unpack_rejects_old_schema_before_reading_new_keys() -> None: + packed = pack_tic_curves(_make_curves()) + with np.load(io.BytesIO(packed), allow_pickle=False) as archive: + payload = {key: archive[key] for key in archive.files} + payload["curves_schema_version"] = np.array("1") + buffer = io.BytesIO() + np.savez_compressed(buffer, **payload) + + with pytest.raises(ValueError, match="只支持 curves schema 3"): + unpack_tic_curves(buffer.getvalue()) + + +def test_pack_rejects_non_monotonic_time_axis() -> None: + curves = _make_curves() + object.__setattr__( + curves, + "time_video_s", + np.asarray([0.0, 0.1, 0.1, 0.3, 0.4], dtype=np.float32), + ) + with pytest.raises(ValueError, match="time_video_s"): + pack_tic_curves(curves) + + +def test_pack_rejects_mismatched_column_length() -> None: + curves = _make_curves() + object.__setattr__(curves, "roi_area_px", np.arange(_T + 2, dtype=np.int32)) + with pytest.raises(ValueError): + pack_tic_curves(curves) + + +def test_pack_rejects_mismatched_curve_length() -> None: + curves = _make_curves() + short = _series(0.0, valid=False) + object.__setattr__(short, "raw", np.zeros(2, dtype=np.float32)) + curves.curves["roi_all"] = short + with pytest.raises(ValueError): + pack_tic_curves(curves) + + +def test_unpack_rejects_pickled_object_payload() -> None: + """构造真实含 object pickle 的恶意 .npz,解包须被拒(``allow_pickle=False``)。""" + buffer = io.BytesIO() + np.savez_compressed(buffer, roi_all_raw=np.array([{"evil": 1}], dtype=object)) + with pytest.raises((ValueError, OSError, KeyError)): + unpack_tic_curves(buffer.getvalue()) + + +def test_pickle_payload_cannot_smuggle_through_unpack() -> None: + """即便 pickle 字节伪装,unpack 内部 ``allow_pickle=False`` 也不得执行反序列化。""" + with pytest.raises((ValueError, OSError, KeyError)): + unpack_tic_curves(pickle.dumps({"evil": "code"})) diff --git a/tests/test_ceus_tic_fitting.py b/tests/test_ceus_tic_fitting.py new file mode 100644 index 0000000..0046780 --- /dev/null +++ b/tests/test_ceus_tic_fitting.py @@ -0,0 +1,230 @@ +"""约束 Lognormal TIC 拟合的纯算法单元测试(照搬旧项目 test_tic_fitting.py)。""" + +import numpy as np +import pytest + +from cadx.ceus import ( + CeusConfig, + LognormalParameters, + TicFittingConfig, + evaluate_lognormal, + fit_lognormal_curve, +) +from cadx.ceus._tic_fitting import _select_primary_peak # pyright: ignore[reportPrivateUsage] +from conftest import load_ceus_section + +# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 +_CONFIG = CeusConfig.model_validate(load_ceus_section()) + + +def _fit( + times: np.ndarray, + values: np.ndarray, + *, + duplicates: np.ndarray | None = None, + baseline: float | None = 30.0, + noise: float | None = 0.2, + cfg: TicFittingConfig | None = None, +): + return fit_lognormal_curve( + times, + values, + baseline=baseline, + baseline_noise=noise, + baseline_end_index=20, + duplicate_mask=(np.zeros(times.size, dtype=bool) if duplicates is None else duplicates), + sample_mask=None, + cfg=cfg or _CONFIG.tic_analysis.fitting, + anchor_cfg=_CONFIG.tic_analysis.anchor_frames, + ) + + +def test_recovers_clean_lognormal_and_continuous_anchors() -> None: + times = np.linspace(0.0, 60.0, 601) + truth = LognormalParameters( + area=400.0, + mu=2.2, + sigma=0.5, + arrival_offset_s=8.0, + baseline=30.0, + ) + result = _fit(times, evaluate_lognormal(times, truth)) + + assert result.success is True + assert result.parameters is not None + assert result.r2 == pytest.approx(1.0, abs=1e-8) + assert result.parameters.arrival_offset_s == pytest.approx(8.0, abs=0.05) + assert result.parameters.sigma == pytest.approx(0.5, abs=0.02) + assert result.d1_time_s is not None + assert result.d1_time_s < result.d2_time_s < result.d3_time_s < result.d4_time_s # pyright: ignore[reportOperatorIssue] + np.testing.assert_allclose(result.processed, result.fitted) + + +def test_soft_l1_fit_survives_single_compression_spike() -> None: + times = np.linspace(0.0, 60.0, 601) + truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0) + values = evaluate_lognormal(times, truth) + values[180] += 80.0 + + result = _fit(times, values) + + assert result.success is True + assert result.parameters is not None + assert result.d3_time_s == pytest.approx( + truth.arrival_offset_s + np.exp(truth.mu - truth.sigma**2), abs=0.25 + ) + + +def test_duplicate_samples_are_excluded_but_keep_original_mapping() -> None: + times = np.linspace(0.0, 60.0, 601) + truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0) + values = evaluate_lognormal(times, truth) + duplicates = np.zeros(times.size, dtype=bool) + duplicates[100:102] = True + + result = _fit(times, values, duplicates=duplicates) + + assert result.success is True + np.testing.assert_array_equal(result.duplicate_sample_mask, duplicates) + assert not result.valid_sample_mask[100:102].any() + assert result.processed.shape == values.shape + + +def test_missing_observed_baseline_estimates_prior_but_rejects_flat_curve() -> None: + times = np.linspace(0.0, 60.0, 601) + values = np.full(times.size, 30.0) + + result = _fit(times, values, baseline=None, noise=None) + + assert result.success is False + assert result.failure_reason == "credible_peak_not_found" + assert result.baseline_source == "estimated" + assert np.isnan(result.processed).all() + assert np.isnan(result.fitted).all() + + +def test_left_truncated_curve_uses_estimated_baseline_and_still_fits() -> None: + times = np.linspace(0.0, 50.0, 501) + truth = LognormalParameters(700.0, 2.7, 0.65, -4.0, 25.0) + + result = _fit( + times, + evaluate_lognormal(times, truth), + baseline=None, + noise=None, + ) + + assert result.success is True + assert result.baseline_source == "estimated" + assert result.parameters is not None + assert result.d3_time_s is not None + + +def test_explicit_sample_mask_excludes_unstable_perf_frame() -> None: + times = np.linspace(0.0, 60.0, 601) + truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0) + values = evaluate_lognormal(times, truth) + values[180] += 100.0 + sample_mask = np.ones(times.size, dtype=bool) + sample_mask[180] = False + + result = fit_lognormal_curve( + times, + values, + baseline=30.0, + baseline_noise=0.2, + baseline_end_index=20, + duplicate_mask=np.zeros(times.size, dtype=bool), + sample_mask=sample_mask, + cfg=_CONFIG.tic_analysis.fitting, + anchor_cfg=_CONFIG.tic_analysis.anchor_frames, + ) + + assert result.success is True + assert result.valid_sample_mask[180] is np.False_ + + +def test_quality_failure_keeps_rejected_fit_separate_from_processed() -> None: + rng = np.random.default_rng(9) + times = np.linspace(0.0, 60.0, 601) + truth = LognormalParameters(160.0, 2.0, 0.55, 8.0, 30.0) + values = evaluate_lognormal(times, truth) + rng.normal(0.0, 18.0, times.size) + cfg = _CONFIG.tic_analysis.fitting.model_copy(update={"minimum_r2": 0.95, "maximum_nrmse": 0.1}) + + result = _fit(times, values, noise=1.0, cfg=cfg) + + assert result.success is False + assert result.failure_reason in { + "fit_quality_failed", + "credible_peak_not_found", + "model_peak_on_boundary", + "parameter_on_boundary", + } + assert np.isnan(result.processed).all() + if result.failure_reason in { + "fit_quality_failed", + "model_peak_on_boundary", + "parameter_on_boundary", + }: + assert result.parameters is not None + assert np.isfinite(result.fitted).all() + assert np.isfinite(result.r2) + assert np.isfinite(result.nrmse) + + +def test_primary_peak_uses_local_finite_run_and_rejects_single_sample_spike() -> None: + grid = np.arange(12.0) + candidate = np.asarray([0.0, 0.0, 10.0, 0.0, np.nan, np.nan, 0.0, 2.5, 5.0, 2.5, 0.0, 0.0]) + + selected = _select_primary_peak([2, 8], grid, candidate, baseline=0.0) + + assert selected == 8 + + +def test_two_boluses_mark_multi_peak_and_fit_first_pass() -> None: + times = np.linspace(0.0, 70.0, 701) + first = evaluate_lognormal(times, LognormalParameters(300.0, 2.0, 0.4, 5.0, 30.0)) + second = evaluate_lognormal(times, LognormalParameters(260.0, 1.8, 0.4, 28.0, 0.0)) + result = _fit(times, first + second) + + assert result.multi_peak_flag is True + assert result.fit_end_s < 34.0 + + +def test_repeated_shallow_motion_peaks_do_not_truncate_first_bolus() -> None: + times = np.linspace(0.0, 70.0, 701) + truth = LognormalParameters(400.0, 2.4, 0.75, 5.0, 30.0) + broad_bolus = evaluate_lognormal(times, truth) + motion = np.where(times > 8.0, 2.0 * np.sin(times * 2.5), 0.0) + + result = _fit(times, broad_bolus + motion) + + assert result.multi_peak_flag is False + assert result.fit_end_s == pytest.approx(times[-1]) + assert result.success is True + + +def test_truncated_washout_does_not_use_fit_boundary_as_d4() -> None: + full_times = np.linspace(0.0, 60.0, 601) + truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0) + full_values = evaluate_lognormal(full_times, truth) + keep = full_times <= 17.0 + + result = _fit(full_times[keep], full_values[keep]) + + assert result.success is True + assert result.d3_time_s is not None + assert result.d4_time_s is None + + +def test_narrow_bolus_keeps_analytic_d4_in_long_video() -> None: + times = np.linspace(0.0, 90.0, 901) + truth = LognormalParameters(400.0, 2.2, 0.15, 8.0, 30.0) + + result = _fit(times, evaluate_lognormal(times, truth)) + + sigma_squared = truth.sigma**2 + wash_out_factor = (sigma_squared - np.sqrt(sigma_squared**2 + 4.0 * sigma_squared)) / 2.0 + expected_d4 = truth.arrival_offset_s + np.exp(truth.mu - sigma_squared - wash_out_factor) + assert result.success is True + assert result.d4_time_s == pytest.approx(expected_d4, abs=0.05) diff --git a/tests/test_ceus_tic_plot.py b/tests/test_ceus_tic_plot.py new file mode 100644 index 0000000..a7e16a4 --- /dev/null +++ b/tests/test_ceus_tic_plot.py @@ -0,0 +1,313 @@ +"""TIC 折线图单元测试:缺测不许画成零(照搬旧项目 test_ceus_tic_plot.py)。""" + +from typing import Any + +import cv2 +import numpy as np +import pytest +from matplotlib.axes import Axes + +from cadx.ceus import ( + CURVE_NAMES, + BaselineResult, + LognormalFitResult, + TicPlotMarker, + TicResult, + render_tic_png, +) + +_ROI_ALL_BGR = (184, 99, 37) +_ROI_PERF_BGR = (0, 103, 212) +_ROI_PERF_RAW_BGR = (194, 219, 245) +_ROI_PAN_BGR = (237, 58, 124) +_ROI_PAN_RAW_BGR = (251, 208, 224) +_MANUAL_AT_BGR = (30, 30, 220) + +_FRAME_COUNT = 21 +# 去暗区曲线中间这一段没采到(NaN),落在时间轴 35%~65% 之间。 +_GAP = range(7, 14) + + +def _invalid_baselines() -> dict[str, BaselineResult]: + """四条曲线都给一个无效基线——这张图只读 ``rows``,基线怎么样它不关心。""" + return { + name: BaselineResult(mean=None, sd=None, frame_start=-1, frame_end=-1, valid=False) + for name in CURVE_NAMES + } + + +def _result( + *, + perf_all_nan: bool, + perf_gap: bool, + pan_available: bool = False, + alternate_background: bool = False, + perf_fit_success: bool | None = None, + pan_fit_success: bool | None = None, + pan_fitted_all_nan: bool = False, + perf_processed_value: float | None = None, + perf_singleton: bool = False, +) -> TicResult: + """造一份 ``roi_perf`` 可缺失或断开的 ``TicResult``。""" + rows: list[dict[str, Any]] = [] + for index in range(_FRAME_COUNT): + perf = float("nan") if perf_all_nan else 90.0 + 10.0 * (index % 2) + if perf_singleton: + perf = 95.0 if index == _FRAME_COUNT // 2 else float("nan") + if perf_gap and index in _GAP: + perf = float("nan") + values = { + "roi_all": 10.0 * index, + "roi_perf": perf, + "roi_pan": 180.0 + index if pan_available else float("nan"), + "background_circle": (150.0 - index if alternate_background else 40.0 + index), + } + row: dict[str, Any] = {"frame_index": index, "time_sec": float(index)} + for name, value in values.items(): + row[f"{name}_raw"] = value + row[f"{name}_processed"] = ( + perf_processed_value + if name == "roi_perf" and perf_processed_value is not None + else value + ) + rows.append(row) + + fits: dict[str, LognormalFitResult] = {} + for name in CURVE_NAMES: + values = np.asarray([row[f"{name}_processed"] for row in rows], dtype=float) + success = bool(np.isfinite(values).any()) + if name == "roi_perf" and perf_fit_success is not None: + success = perf_fit_success + if name == "roi_pan" and pan_fit_success is not None: + success = pan_fit_success + fitted = values.copy() + if name == "roi_pan" and pan_fitted_all_nan: + fitted[:] = np.nan + fits[name] = LognormalFitResult( + success=success, + failure_reason="" if success else "no_data", + algorithm_version="lognormal-v2", + curve_model="lognormal", + smoothing_method="", + baseline_source="observed", + candidate=values.copy(), + fitted=fitted, + processed=values.copy(), + valid_sample_mask=np.isfinite(values), + duplicate_sample_mask=np.zeros(_FRAME_COUNT, dtype=bool), + parameters=None, + r2=float("nan"), + nrmse=float("nan"), + residual_noise_ratio=float("nan"), + fit_start_s=0.0, + fit_end_s=float(_FRAME_COUNT - 1), + multi_peak_flag=False, + d1_time_s=None, + d2_time_s=None, + d3_time_s=None, + d4_time_s=None, + ) + return TicResult( + rows=rows, + parameters=None, + baselines=_invalid_baselines(), + fits=fits, + early_truncation_flag=False, + reference_valid=pan_available, + pan_detected_fraction=1.0 if pan_available else 0.0, + ) + + +def _decode(png: bytes) -> np.ndarray: + canvas = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_COLOR) + assert canvas is not None, "渲染出来的字节解不回一张图" + return canvas + + +def _render(*, perf_all_nan: bool, perf_gap: bool) -> np.ndarray: + return _decode( + render_tic_png(_result(perf_all_nan=perf_all_nan, perf_gap=perf_gap), markers=()) + ) + + +def _color_pixels(canvas: np.ndarray, color: tuple[int, int, int]) -> np.ndarray: + difference = np.abs(canvas.astype(np.int16) - np.asarray(color, dtype=np.int16)) + return np.all(difference <= 8, axis=2) + + +def test_all_nan_curve_is_not_drawn_at_zero() -> None: + """``roi_perf`` 整条 NaN 时不画;同一条有数据时画得出来。 + + 第二句是要害:只断言「没画」的话,一个「什么都不画」的实现照样能过。 + """ + absent = _color_pixels(_render(perf_all_nan=True, perf_gap=False), _ROI_PERF_BGR) + present = _color_pixels(_render(perf_all_nan=False, perf_gap=False), _ROI_PERF_BGR) + + assert int(absent.sum()) == 0, "整条 NaN 的曲线被画出来了——多半是被当成了 0" + assert int(present.sum()) > 0, "同一条曲线有数据时反而没画出来" + + +def test_nan_gap_breaks_the_polyline_instead_of_dipping_to_zero() -> None: + canvas = _render(perf_all_nan=False, perf_gap=True) + perf_pixels = _color_pixels(canvas, _ROI_PERF_BGR) + height, width = canvas.shape[:2] + inside_gap = perf_pixels[ + int(0.08 * height) : int(0.62 * height), + int(0.45 * width) : int(0.55 * width), + ] + + assert int(inside_gap.sum()) == 0, "缺口那一段仍有去暗区曲线像素——折线没有断开" + assert int(perf_pixels.sum()) > 0, "整条曲线都没画出来——断开断过头了" + + +def test_curves_with_data_are_still_drawn() -> None: + """有数据的曲线照画不误(防止「遇 NaN 就整条放弃」)。""" + canvas = _render(perf_all_nan=True, perf_gap=True) + assert int(_color_pixels(canvas, _ROI_ALL_BGR).sum()) > 0 + + +def test_isolated_raw_sample_is_visible() -> None: + result = _result( + perf_all_nan=True, + perf_gap=False, + perf_fit_success=False, + perf_singleton=True, + ) + canvas = _decode(render_tic_png(result, markers=())) + height, width = canvas.shape[:2] + plot_center = _color_pixels(canvas, _ROI_PERF_RAW_BGR)[ + int(0.2 * height) : int(0.65 * height), + int(0.45 * width) : int(0.55 * width), + ] + + assert int(plot_center.sum()) > 0 + + +def test_rejected_fit_keeps_raw_but_hides_processed_curve() -> None: + result = _result( + perf_all_nan=False, + perf_gap=False, + perf_fit_success=False, + perf_processed_value=170.0, + ) + canvas = _decode(render_tic_png(result, markers=())) + + assert int(_color_pixels(canvas, _ROI_PERF_RAW_BGR).sum()) > 0 + assert int(_color_pixels(canvas, _ROI_PERF_BGR).sum()) == 0 + + +def test_pan_reference_raw_and_fit_are_drawn_when_available() -> None: + result = _result( + perf_all_nan=False, + perf_gap=False, + pan_available=True, + ) + canvas = _decode(render_tic_png(result, markers=())) + + assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0 + assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) > 0 + + +def test_missing_pan_reference_is_not_drawn_at_zero() -> None: + canvas = _decode(render_tic_png(_result(perf_all_nan=False, perf_gap=False), markers=())) + + assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) == 0 + assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) == 0 + + +def test_rejected_pan_fit_draws_diagnostic_fitted_curve() -> None: + result = _result( + perf_all_nan=False, + perf_gap=False, + pan_available=True, + pan_fit_success=False, + ) + canvas = _decode(render_tic_png(result, markers=())) + + assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0 + assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) > 0 + + +def test_rejected_pan_fit_is_dashed_and_labeled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + plotted_styles: list[dict[str, Any]] = [] + original_plot = Axes.plot # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] + + def capture_plot(self: Axes, *args: Any, **kwargs: Any) -> Any: + plotted_styles.append(kwargs) + return original_plot(self, *args, **kwargs) + + monkeypatch.setattr(Axes, "plot", capture_plot) + render_tic_png( + _result( + perf_all_nan=False, + perf_gap=False, + pan_available=True, + pan_fit_success=False, + ), + markers=(), + ) + + reference_fit = next( + style + for style in plotted_styles + if style.get("label") == "roi_pan reference Lognormal fit [QC rejected]" + ) + assert reference_fit["linestyle"] == "--" + + +def test_rejected_pan_fit_without_finite_model_does_not_fabricate_line() -> None: + result = _result( + perf_all_nan=False, + perf_gap=False, + pan_available=True, + pan_fit_success=False, + pan_fitted_all_nan=True, + ) + canvas = _decode(render_tic_png(result, markers=())) + + assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0 + assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) == 0 + + +def test_background_does_not_change_tic_plot() -> None: + regular = _result(perf_all_nan=False, perf_gap=False) + changed = _result( + perf_all_nan=False, + perf_gap=False, + alternate_background=True, + ) + regular_canvas = _decode(render_tic_png(regular, markers=())) + changed_canvas = _decode(render_tic_png(changed, markers=())) + + np.testing.assert_array_equal(regular_canvas, changed_canvas) + + +def test_plot_uses_review_ready_canvas_size() -> None: + canvas = _render(perf_all_nan=False, perf_gap=False) + + assert canvas.shape == (800, 1600, 3) + + +def test_review_marker_is_drawn_without_changing_curve_data() -> None: + result = _result(perf_all_nan=False, perf_gap=False) + png = render_tic_png( + result, + markers=(TicPlotMarker("manual AT", 10.0, _MANUAL_AT_BGR),), + ) + canvas = _decode(png) + + assert int(_color_pixels(canvas, _MANUAL_AT_BGR).sum()) > 0 + assert int(_color_pixels(canvas, _ROI_PERF_BGR).sum()) > 0 + + +def test_marker_outside_observed_time_is_not_attached_to_boundary() -> None: + result = _result(perf_all_nan=False, perf_gap=False) + png = render_tic_png( + result, + markers=(TicPlotMarker("manual AT", 100.0, _MANUAL_AT_BGR),), + ) + canvas = _decode(png) + + assert int(_color_pixels(canvas, _MANUAL_AT_BGR).sum()) == 0 diff --git a/tests/test_ceus_trajectory.py b/tests/test_ceus_trajectory.py new file mode 100644 index 0000000..c2ba6cb --- /dev/null +++ b/tests/test_ceus_trajectory.py @@ -0,0 +1,260 @@ +"""trajectory npz 打包/解包单元测试(照搬旧项目 test_ceus_trajectory.py)。 + +旧标题:B11 trajectory npz 打包/解包单元测试。 + +钉死轨迹工件的单一 schema:完整往返还原全部键(沿用参考 ``write_roi_trajectory_npz`` +键集 + 新增 ``keyframe_indices`` / ``dark_polygons`` / ``calibration_json``)、 +各键 dtype 精确(int32 / float32 / uint8 / str)、变长 ``dark_polygons``(逐帧 +不同数量多边形)与 ``keyframe_indices`` 往返、``reference_region`` / ``calibration`` +dict 往返,以及防御:坏字节解包 raise、形状不一致 pack raise、禁 pickle +(含 object pickle 的恶意 npz 解包被拒)。测试用真实数据的二次构造(合成多边形 / +掩膜),边缘场景靠实现里的 assertion/raise 保证。 +""" + +import io +import pickle + +import numpy as np +import pytest + +from cadx.ceus import ( + Trajectory, + ViewRect, + pack_trajectory, + unpack_trajectory, +) + +# Phase 1: 构造一个真实形态的 Trajectory(二次构造的合成几何) + + +def _make_polygon(offset: float) -> np.ndarray: + """构造 (64, 2) float32 闭合多边形(单位圆平移),与重采样输出形态一致。""" + angles = np.linspace(0.0, 2.0 * np.pi, 64, endpoint=False) + points = np.stack([np.cos(angles), np.sin(angles)], axis=1) * 10.0 + offset + return points.astype(np.float32) + + +def _make_trajectory() -> Trajectory: + num_frames = 3 + target_polygons = [_make_polygon(float(i)) for i in range(num_frames)] + source_polygons = [_make_polygon(float(i) + 100.0) for i in range(num_frames)] + # 逐帧暗区多边形数量不同:0 个 / 1 个 / 2 个(变长嵌套) + dark_polygons: list[list[np.ndarray]] = [ + [], + [_make_polygon(5.0)], + [_make_polygon(6.0), _make_polygon(7.0)], + ] + reference_mask = np.zeros((20, 30), dtype=np.uint8) + reference_mask[5:10, 5:15] = 1 + return Trajectory( + frame_indices=np.arange(num_frames, dtype=np.int32), + fps=29.97, + target_polygons=np.asarray(target_polygons, dtype=np.float32), + source_polygons=np.asarray(source_polygons, dtype=np.float32), + source_view_rect=ViewRect(0, 0, 100, 200), + target_view_rect=ViewRect(100, 0, 200, 200), + source_effective_rect=ViewRect(5, 5, 95, 195), + target_effective_rect=ViewRect(105, 5, 195, 195), + source_polygon_coordinate_space="source_view_local", + target_polygon_coordinate_space="target_effective_view_local", + reference_mask=reference_mask, + reference_region={"frame_index": 1, "area": 50.0, "source": "auto"}, + keyframe_indices=np.asarray([1, 2], dtype=np.int32), + dark_polygons=dark_polygons, + calibration={ + "source": "config/ceus.yaml", + "config_hash": "abc123", + "cli_override": False, + "detected_split_x": 100, + }, + ) + + +# Phase 2: 完整往返 —— 全部键还原 + + +def test_roundtrip_preserves_all_keys() -> None: + trajectory = _make_trajectory() + restored = unpack_trajectory(pack_trajectory(trajectory)) + + np.testing.assert_array_equal(restored.frame_indices, trajectory.frame_indices) + assert restored.fps == pytest.approx(trajectory.fps) + np.testing.assert_allclose(restored.target_polygons, trajectory.target_polygons) + np.testing.assert_allclose(restored.source_polygons, trajectory.source_polygons) + assert restored.source_view_rect == trajectory.source_view_rect + assert restored.target_view_rect == trajectory.target_view_rect + assert restored.source_effective_rect == trajectory.source_effective_rect + assert restored.target_effective_rect == trajectory.target_effective_rect + assert restored.source_polygon_coordinate_space == trajectory.source_polygon_coordinate_space + assert restored.target_polygon_coordinate_space == trajectory.target_polygon_coordinate_space + np.testing.assert_array_equal(restored.reference_mask, trajectory.reference_mask) + assert restored.reference_region == trajectory.reference_region + np.testing.assert_array_equal(restored.keyframe_indices, trajectory.keyframe_indices) + assert restored.calibration == trajectory.calibration + + +# Phase 3: dtype 精确 + + +def test_roundtrip_dtypes_are_exact() -> None: + restored = unpack_trajectory(pack_trajectory(_make_trajectory())) + assert restored.frame_indices.dtype == np.int32 + assert restored.target_polygons.dtype == np.float32 + assert restored.source_polygons.dtype == np.float32 + assert restored.reference_mask.dtype == np.uint8 + assert restored.keyframe_indices.dtype == np.int32 + assert isinstance(restored.fps, float) + assert isinstance(restored.source_polygon_coordinate_space, str) + assert isinstance(restored.target_polygon_coordinate_space, str) + + +def test_polygon_shapes_are_n_64_2() -> None: + restored = unpack_trajectory(pack_trajectory(_make_trajectory())) + assert restored.target_polygons.shape == (3, 64, 2) + assert restored.source_polygons.shape == (3, 64, 2) + + +# Phase 3b: 存档层 schema —— 直接在 npz 原始存档上断言键名 / dtype / shape +# (对象层往返不足以守住 pack 的存储 schema;pack 若 schema 漂移须在此被发现) + + +def test_archive_schema_keys_dtypes_shapes() -> None: + """打开 pack 产出的原始 npz 存档,逐一断言键集 / 存储 dtype / shape。 + + 与对象层往返互补:往返只验 unpack(pack(x))==x,无法发现 pack 写入存档时的 + dtype / shape 漂移(如 fps 被写成 float64、polygons 退化)。此处用 + ``allow_pickle=False`` 直接读取存档,钉死存储 schema。 + """ + archive_bytes = pack_trajectory(_make_trajectory()) + with np.load(io.BytesIO(archive_bytes), allow_pickle=False) as archive: + # 键集合 == 全部必要键(无多无少) + expected_keys = { + "frame_indices", + "fps", + "target_polygons", + "source_polygons", + "source_view_rect", + "target_view_rect", + "source_effective_rect", + "target_effective_rect", + "source_polygon_coordinate_space", + "target_polygon_coordinate_space", + "reference_mask", + "reference_region_json", + "keyframe_indices", + "dark_polygons", + "calibration_json", + } + assert set(archive.files) == expected_keys + + # 存档内 dtype 精确(存储 dtype 是 schema 的一部分) + assert archive["frame_indices"].dtype == np.int32 + assert archive["keyframe_indices"].dtype == np.int32 + assert archive["target_polygons"].dtype == np.float32 + assert archive["source_polygons"].dtype == np.float32 + # fps 存档 dtype 必须 float32(即使 in-memory unpack 还原为 Python float) + assert archive["fps"].dtype == np.float32 + for rect_key in ( + "source_view_rect", + "target_view_rect", + "source_effective_rect", + "target_effective_rect", + ): + assert archive[rect_key].dtype == np.int32 + assert archive["reference_mask"].dtype == np.uint8 + + # shape 精确 + assert archive["target_polygons"].shape == (3, 64, 2) + assert archive["source_polygons"].shape == (3, 64, 2) + assert archive["frame_indices"].shape == (3,) + for rect_key in ( + "source_view_rect", + "target_view_rect", + "source_effective_rect", + "target_effective_rect", + ): + assert archive[rect_key].shape == (4,) + assert archive["reference_mask"].ndim == 2 + + +# Phase 4: 变长 dark_polygons 往返 + + +def test_roundtrip_variable_length_dark_polygons() -> None: + trajectory = _make_trajectory() + restored = unpack_trajectory(pack_trajectory(trajectory)) + assert len(restored.dark_polygons) == 3 + assert len(restored.dark_polygons[0]) == 0 + assert len(restored.dark_polygons[1]) == 1 + assert len(restored.dark_polygons[2]) == 2 + for original_frame, restored_frame in zip( + trajectory.dark_polygons, restored.dark_polygons, strict=True + ): + for original_poly, restored_poly in zip(original_frame, restored_frame, strict=True): + assert isinstance(restored_poly, np.ndarray) + assert restored_poly.dtype == np.float32 + np.testing.assert_allclose(restored_poly, original_poly) + + +def test_roundtrip_empty_dark_polygons() -> None: + trajectory = _make_trajectory() + trajectory.dark_polygons = [[], [], []] + restored = unpack_trajectory(pack_trajectory(trajectory)) + assert restored.dark_polygons == [[], [], []] + + +# Phase 5: 防御 —— 坏字节 / 形状不一致 / 禁 pickle + + +def test_unpack_bad_bytes_raises() -> None: + with pytest.raises((ValueError, OSError)): + unpack_trajectory(b"this-is-not-a-valid-npz-archive") + + +def test_pack_rejects_mismatched_frame_count() -> None: + trajectory = _make_trajectory() + # frame_indices 长度与多边形帧数不一致 + trajectory.frame_indices = np.arange(5, dtype=np.int32) + with pytest.raises(ValueError): + pack_trajectory(trajectory) + + +def test_pack_rejects_wrong_polygon_shape() -> None: + trajectory = _make_trajectory() + # 把 target_polygons 改成 (N, 32, 2),违反 (N, 64, 2) + trajectory.target_polygons = np.zeros((3, 32, 2), dtype=np.float32) + with pytest.raises(ValueError): + pack_trajectory(trajectory) + + +def test_pack_rejects_non_2d_reference_mask() -> None: + trajectory = _make_trajectory() + trajectory.reference_mask = np.zeros((20, 30, 3), dtype=np.uint8) + with pytest.raises(ValueError): + pack_trajectory(trajectory) + + +def test_unpack_rejects_pickled_object_payload() -> None: + """构造真实含 object pickle 的恶意 .npz(须 allow_pickle 才能 save/load),解包须被拒。 + + 关键:用 ``np.savez_compressed`` 生成真正的 .npz 存档(而非 .npy),其中含一个 + object dtype 数组(背后是 pickle payload)。这覆盖了「合法 .npz 容器、但成员需 + pickle 反序列化」的注入面——``unpack_trajectory`` 内部 ``allow_pickle=False`` 必须 + 在访问该成员时 raise,绝不静默反序列化执行。 + """ + buffer = io.BytesIO() + # 把 object dtype 数组放进 unpack 首先访问的必要键 frame_indices,确保命中 + # allow_pickle=False 对 object 成员的拒绝路径(而非仅缺键 KeyError)。 + # object dtype 数组在 savez 时即以 pickle 存储,构造真实 .npz 字节。 + np.savez_compressed(buffer, frame_indices=np.array([{"evil": 1}], dtype=object)) + raw = buffer.getvalue() + # 访问 frame_indices 这一 object 成员时,allow_pickle=False 必须 raise(ValueError) + with pytest.raises((ValueError, OSError, KeyError)): + unpack_trajectory(raw) + + +def test_pickle_payload_cannot_smuggle_through_unpack() -> None: + """即便 pickle 字节伪装,unpack 内部 allow_pickle=False 也不得执行反序列化。""" + payload = pickle.dumps({"evil": "code"}) + with pytest.raises((ValueError, OSError, KeyError)): + unpack_trajectory(payload) diff --git a/tests/test_segment.py b/tests/test_segment.py new file mode 100644 index 0000000..f03667a --- /dev/null +++ b/tests/test_segment.py @@ -0,0 +1,95 @@ +"""segment:分割设置的校验,以及在合成小视频上真跑一次 MedSAM2(`gpu`)。""" + +from pathlib import Path +from typing import Any, cast + +import cv2 +import numpy as np +import numpy.typing as npt +import pytest +import yaml +from pydantic import ValidationError + +from cadx.segment import SegmentConfig, medsam2_commit, segment_video + +REPO_ROOT = Path(__file__).resolve().parents[1] +CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml" + + +def _segment_section() -> dict[str, Any]: + document = cast(dict[str, Any], yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8"))) + return cast(dict[str, Any], document["segment"]) + + +def _absolute(section: dict[str, Any]) -> dict[str, Any]: + """路径字段换成仓库根目录下的绝对路径(脚本也这样做)。""" + return { + **section, + "medsam2_dir": str(REPO_ROOT / section["medsam2_dir"]), + "checkpoint": str(REPO_ROOT / section["checkpoint"]), + } + + +def test_config_yaml_section_validates() -> None: + cfg = SegmentConfig.model_validate(_segment_section()) + assert cfg.sam2_config == "configs/sam2.1_hiera_t512.yaml" + + +@pytest.mark.parametrize( + "field", + [ + "medsam2_dir", + "checkpoint", + "sam2_config", + "device", + "offload_video_to_cpu", + "offload_state_to_cpu", + ], +) +def test_config_missing_field_raises(field: str) -> None: + section = _segment_section() + del section[field] + with pytest.raises(ValidationError, match=field): + SegmentConfig.model_validate(section) + + +def test_config_extra_field_raises() -> None: + with pytest.raises(ValidationError, match="object_id"): + SegmentConfig.model_validate({**_segment_section(), "object_id": 1}) + + +def test_config_bool_not_coerced() -> None: + with pytest.raises(ValidationError, match="offload_video_to_cpu"): + SegmentConfig.model_validate({**_segment_section(), "offload_video_to_cpu": "false"}) + + +def _moving_disc_video( + frame_count: int, height: int, width: int, radius: int +) -> tuple[list[npt.NDArray[np.uint8]], list[npt.NDArray[np.bool_]]]: + """暗背景上一个亮圆从左往右匀速移动;交出逐帧 BGR 图像和每帧圆的真值掩码。""" + frames: list[npt.NDArray[np.uint8]] = [] + truths: list[npt.NDArray[np.bool_]] = [] + yy, xx = np.mgrid[0:height, 0:width] + for index in range(frame_count): + cx = radius + 10 + index * 2 + cy = height // 2 + frame = np.full((height, width, 3), 30, dtype=np.uint8) + cv2.circle(frame, (cx, cy), radius, (220, 220, 220), thickness=-1) + frames.append(frame) + truths.append((xx - cx) ** 2 + (yy - cy) ** 2 <= radius**2) + return frames, truths + + +@pytest.mark.gpu +def test_segment_video_on_synthetic_clip() -> None: + cfg = SegmentConfig.model_validate(_absolute(_segment_section())) + frame_count, height, width = 40, 120, 200 + frames, truths = _moving_disc_video(frame_count, height, width, radius=18) + # 两个提示帧,最早的不在第 0 帧,前向和反向传播都要走到。 + prompt_masks = {5: truths[5], 25: truths[25]} + + masks = segment_video(frames, prompt_masks, cfg) + + assert masks.shape == (frame_count, height, width) + assert masks.dtype == np.bool_ + assert len(medsam2_commit(cfg.medsam2_dir)) == 40 diff --git a/uv.lock b/uv.lock index 63542b3..3eb9581 100644 --- a/uv.lock +++ b/uv.lock @@ -1,48 +1,66 @@ version = 1 revision = 2 requires-python = ">=3.12" +resolution-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'linux'", +] +supported-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'linux'", +] [[package]] name = "annotated-types" version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" }, ] +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b" } + [[package]] name = "cadx" version = "0.0.0" source = { editable = "." } dependencies = [ - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "openpyxl" }, - { name = "pillow" }, - { name = "pydantic" }, - { name = "pyyaml" }, + { name = "matplotlib", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "medsam2", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "opencv-python-headless", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "openpyxl", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pillow", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pydantic", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pyyaml", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "scipy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] [package.dev-dependencies] dev = [ - { name = "import-linter" }, - { name = "pre-commit" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, - { name = "types-openpyxl" }, - { name = "types-pyyaml" }, + { name = "import-linter", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pre-commit", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pyright", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pytest", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "ruff", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "types-openpyxl", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "types-pyyaml", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] [package.metadata] requires-dist = [ + { name = "matplotlib", specifier = ">=3.11.2" }, + { name = "medsam2", editable = "reference/MedSAM2" }, { 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" }, { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "scipy", specifier = ">=1.18.1" }, ] [package.metadata.requires-dev] @@ -60,143 +78,249 @@ dev = [ name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0" }, ] [[package]] name = "click" version = "8.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" }, ] [[package]] -name = "colorama" -version = "0.4.6" +name = "contourpy" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +dependencies = [ + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f" }, + { url = "https://files.pythonhosted.org/packages/a6/5e/9a7241a89e2f74402aefa2569e1349ad2750ff77823c04c788eaaedb7fdd/contourpy-1.4.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f861af508fca2384eef392bc1d8377cfa349b2b854cb11b45d89c75780e2561" }, + { url = "https://files.pythonhosted.org/packages/36/81/3a4fbbf6a806eada5453a6d16f6c7d96b9f4b3e57a3127368fb545986b8a/contourpy-1.4.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a03b32c2e7eda8b17757c022162c13c86f5c3d6f937dddf9ba3c3ff7b513953b" }, + { url = "https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70" }, + { url = "https://files.pythonhosted.org/packages/fd/8a/2301d07accb257ac08daeb631875fada06fedd019c20ff7b00ee07e9d211/contourpy-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ced1670fafee703b8277ab1645072a226e74f167166eae146fb743916119b67" }, + { url = "https://files.pythonhosted.org/packages/6d/99/7b358ce888aaa426f755daa096c99c59d17c352037344e836f20e5d3b957/contourpy-1.4.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1" }, + { url = "https://files.pythonhosted.org/packages/be/02/34d7548adf08c60967435d79d7a90882eacc84f5093435287974c7e99943/contourpy-1.4.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55" }, + { url = "https://files.pythonhosted.org/packages/cf/8c/9677dad226e5aaac5b823d5b09d881ebc32ff1648af417597649841e516c/contourpy-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6" }, + { url = "https://files.pythonhosted.org/packages/88/be/55fec293d342c222ed58a5d4056df4362e192af2e59527a3d5d822de8828/contourpy-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9" }, + { url = "https://files.pythonhosted.org/packages/c7/85/e0952576d54322f3e9983b1e049a7a31f42ce82c5c29d01821e6dd3780d7/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/f13543a5e1598e4a4ad01d9d5e2d65f9c09c37660616429913d606c5977d/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114" }, + { url = "https://files.pythonhosted.org/packages/c5/a8/6e5eaff53507dfd2dfc428240eecda32453067277d4b3e7ca71210b2d611/contourpy-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f" }, + { url = "https://files.pythonhosted.org/packages/1e/01/1e533b0cae32c8edd63fe40435ec3b29345dab87321afcd28722d7f957d1/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c" }, + { url = "https://files.pythonhosted.org/packages/56/4c/fa8de87d907b41b05d03f4945a51675cd67319650d01b4c927d209ffa6ee/contourpy-1.4.0-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630d32f06cedf37b7f1dd2b6a12e4492826bd477a4c9c7b2e2fee6ce3a2cea76" }, + { url = "https://files.pythonhosted.org/packages/04/e7/96bd4f4a4ae285727befbdb4b332d53a2654cc5da4585d2525b47a063e1f/contourpy-1.4.0-cp315-cp315-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be85d160e7c795113c2a93254dde2fc7e86b375322570eee78b420969426973c" }, + { url = "https://files.pythonhosted.org/packages/ae/94/39660e8dc309f96bf7971eb4ec5659f272f84e42b34c9990a96ecce8442c/contourpy-1.4.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018227e134b73090b06f911e773a526c77c9af426be29065500ce096a5accd4c" }, + { url = "https://files.pythonhosted.org/packages/a0/39/bf2442130e7d5aae75a4de4504f0c8aca3ef3da3feac3940ee27ca985d83/contourpy-1.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc87f2ffa84a49a77d76cb792f28a48ff3b05a222aa0b2bddbd839ba78e1d5ca" }, + { url = "https://files.pythonhosted.org/packages/47/75/5a9942446647202beb7b4d150891bfcb78fb112c73862484bbe4cc031092/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bee6ff96653e0807cd0f8eaef00174a031e3a2effa5c20f49a5a8d574b05af23" }, + { url = "https://files.pythonhosted.org/packages/c3/4f/bfdb0c67775a584f957211936661289789c4d0ec3e988bca2c8c02f8090e/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a58b5f130afec61a093d743cdda435cc4047748b6791627e34a68f41ec3a9b07" }, + { url = "https://files.pythonhosted.org/packages/65/d3/dddf13d6bb26e145c99debd1f2695018b5f3e24452dd9bfc9904c9d2ec89/contourpy-1.4.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9dc493e7924e5aa32d89d0b8e50986280f3d4b284968a792787bd7bcffd3cad" }, + { url = "https://files.pythonhosted.org/packages/5d/29/fc91cc1ac6f3b591a269f4357b656d6ed0c39eb17c148f41f6da2f9729b9/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:80c7fc8e7ac217777ce7ba2db3cf8478652b5a4b335283111012ff9eaac9d842" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f8/332443cf969734551891cdbd93657eaeaf747ac2b7dd73feb3422701b02b/cuda_bindings-13.4.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:433efa31e33c2868621b5f6036bd3bf5de9c45c5546cb1361a5f6ff3415b8bb5" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/aedec367fea64267594a26ede3704f241e1bee465962f3a92d6186c774b7/cuda_bindings-13.4.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d6adc44f098472d8ac2d6211923eea6081830521852dc77f8c9237bf9320a59" }, + { url = "https://files.pythonhosted.org/packages/15/65/0b8f2f2509e5f1a8e556a2e083ac0287fe4af79afdba3153454229306f39/cuda_bindings-13.4.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:680e70dcb8b61fdae16d3bb4876c41d55aadc9fe8b3825ea567f2f2c85606b0a" }, + { url = "https://files.pythonhosted.org/packages/90/44/f6325a6061f1fc619e216dd6c7cbd9a0b475d3937bb32296c84cc27e6eeb/cuda_bindings-13.4.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ce9208c7cf03a947ac11553b08ee2fca594ac63d34360639a9060980f546a62" }, + { url = "https://files.pythonhosted.org/packages/63/88/5b50b13cef9ae8baadcca03a47cf0e9146c0b52ca6aeabaf50fc17e18445/cuda_bindings-13.4.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6781fe08ed5f4b5163a3c6577e079bc28955811382d35995c916bf052ee0994e" }, + { url = "https://files.pythonhosted.org/packages/c8/5d/ca0d5c1d9c09a9de15f474c1fa6abe801761e75cdc1be9a03431063f2af5/cuda_bindings-13.4.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:baa0f5f1f9a05032e7deb97051e85aa18b4e3c6db914f1c813bb2f02e5c72db4" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fb/f8e1890428f9f590b4beebd63b068aac1ce32a3331510c847b9f9a78f261/cuda_pathfinder-1.8.3-py3-none-any.whl", hash = "sha256:e29e59829c297a7a5233bd9cc71094fc5bddbd076951482670178f9eade39b1f" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30" }, ] [[package]] name = "distlib" version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b" }, ] [[package]] name = "et-xmlfile" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa" }, ] [[package]] name = "filelock" version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e", size = 569667, upload-time = "2026-10-14T20:57:13.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238", size = 135032, upload-time = "2026-10-14T20:57:11.349Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238" }, +] + +[[package]] +name = "fonttools" +version = "4.67.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/36/102e180f8f5dbaee88b26595b01ca8aa80bf4e62128d9aa94265b3996c96/fonttools-4.67.0.tar.gz", hash = "sha256:3cb57e6600ca77c0b1729cf8adc23bc0652633a37f18cfa934d9c7bc3de25519" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e0/ec9e4cc868c514deb02233aa1047a6aeb9350d3ee012862f58eec10ef834/fonttools-4.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb3c98cac93aac4b9f6e3ce2008325340b234cc9b0338ca6b513f31962a1e278" }, + { url = "https://files.pythonhosted.org/packages/de/5b/2a8dede092113be56329dd210deb6b34c55df2f3d7270934ffece8c7d0bb/fonttools-4.67.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2a09d33a9264a6b29efca9dc633b53969aaedb250a9c8521d60f51280cef65ca" }, + { url = "https://files.pythonhosted.org/packages/a8/26/939ae9874dd44116f2ecf61cb0caf029e3004ec1ed311a86389dee3450be/fonttools-4.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50c41e30aa2e0130b80d1a58ac0f3ea7c02a854a70dbea1ff8d88e0ce524806f" }, + { url = "https://files.pythonhosted.org/packages/bc/90/293577941809c3ec5a7f0870c01b3729c682467a858b8978a5c3ea54c226/fonttools-4.67.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36f0fee56227b909c9d1392f17b23803616f1f04efbe020c176d9945cabc0be5" }, + { url = "https://files.pythonhosted.org/packages/79/fe/fef04b2cc2930edba11095f9e9b5c2797f8594fc54316195cc39d3c3bc63/fonttools-4.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:916836845e4b1c1447bb61390ffb3cb5f2940fd9f5d6de4685539a81806c7764" }, + { url = "https://files.pythonhosted.org/packages/53/5c/08abd0a6d5c36624411e1b934745b4689d4309b03e98d8cf49f9469c63b6/fonttools-4.67.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b3ddf350e74508102b33dc6b32984b6dd751359a7c57732bcd39f9d7cb37d71e" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/495fe0a6bb8625e693c1417e178aeac42a11aa47e79efd7611c7bc5fb81e/fonttools-4.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:720bcf27727193b0fe1883c2e036dc88e37047916e977f5c3daf6ee4316e9656" }, + { url = "https://files.pythonhosted.org/packages/f0/c6/d41c1163431828b0fa2172e867798e0c4517ac6606e774b9175e048fb666/fonttools-4.67.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:13d7507252c5a5d7941a5fa1be27d335c378ef07983ea2bb24988bf600eadd5e" }, + { url = "https://files.pythonhosted.org/packages/3c/db/66b5ef9985c7d69f7b3521ee965c3093b1802322fb6c16e8c3da608b747e/fonttools-4.67.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f200cd2cf046a5a0b03babe84ebf8bbc12187d5d57f50bc03f24be89e7c1605" }, + { url = "https://files.pythonhosted.org/packages/97/b8/d3e7b799186fc3213a31d0cfa2c553c5d8eed0a7c7960dc3cf7c0d0497fa/fonttools-4.67.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b274ed3106b8086f237b7dbb1529c28142ba10ae40b9d285be0ae6a44b2946d0" }, + { url = "https://files.pythonhosted.org/packages/c2/8c/01f2f16066c802ad2cd6f3321c226240475b30ada91d69d493f7a40445a7/fonttools-4.67.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ccaa87b312219d02cf72a79f1eb2f3ce028882d6fd1b79336141005db83b84e" }, + { url = "https://files.pythonhosted.org/packages/39/c8/4de02224adea134666e6705b0137cd3df2df60a03ce100797b2b221a73dd/fonttools-4.67.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f672398385849ff79e7dd50c0a06efe110c8ba23d8890f9b45fbb922bc2f55f6" }, + { url = "https://files.pythonhosted.org/packages/3d/61/4161946319472aaa9b897bd18ad5108a5b10f5ebaa503d921a001ac4fff9/fonttools-4.67.0-py3-none-any.whl", hash = "sha256:4304f03ed7f4ba000a8dcc941ad854bfa52e2f3b6112b8f099b6f431cf98e701" }, +] + +[[package]] +name = "fsspec" +version = "2026.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f" }, ] [[package]] name = "grimp" version = "3.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ad/2e/95736b7c984705ff5ee376f07c6e71bb80fcbb22c8fa3b8c1cfbfa919d5c/grimp-3.17.tar.gz", hash = "sha256:5161e03c6f518fe4da43b06c97ff49a1e224ecb0e4b9fd6ef48235373d1b7b33", size = 832923, upload-time = "2026-09-04T11:28:54.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/2e/95736b7c984705ff5ee376f07c6e71bb80fcbb22c8fa3b8c1cfbfa919d5c/grimp-3.17.tar.gz", hash = "sha256:5161e03c6f518fe4da43b06c97ff49a1e224ecb0e4b9fd6ef48235373d1b7b33" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/ba/e2e37adeac007a42b074c9156b3a155692fc5a1a32008fb8385546e5c92f/grimp-3.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7375c3aea01495839360947df321d26dcebd037ef34a641627456f23fd91c63e", size = 2146861, upload-time = "2026-09-04T11:28:10.923Z" }, - { url = "https://files.pythonhosted.org/packages/97/d0/3ac947eb15b7c6fe615e01cdfef8152e8b2509c6d5439f7799fae1ca024c/grimp-3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7e75804d1d7f11813082b30a0cd7cf84f553c55d7c1edc28cde23656f1d1a40", size = 2090629, upload-time = "2026-09-04T11:28:03.234Z" }, - { url = "https://files.pythonhosted.org/packages/89/df/53d3eb18c3197ae2bb6c8be118026870cb89b2c33f99c7157345bc5e0734/grimp-3.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62f61d14aedfc0df57db6f7bc826df0f77f3c7115a2a5525aa76531ce2cb9fa6", size = 2277265, upload-time = "2026-09-04T11:27:00.481Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a7/6615a81f83d8ee31c96b1d8fa9a5d12fceb82acc17a9e71b28768b8185b5/grimp-3.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b1517920dd93df17f6cf0bb79a1ddf193993e87a60dec30bbe67a5139187f0", size = 2220699, upload-time = "2026-09-04T11:27:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/39/60/487bb910bb5092a392f8b23ae8c5fde6d766bfe02e2c7a064ae9a14da30a/grimp-3.17-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d55d03f5dc704b18fa7da04e7eebcc3b16b998b1e18818fb750e291b2991900", size = 2371257, upload-time = "2026-09-04T11:27:38.482Z" }, - { url = "https://files.pythonhosted.org/packages/73/52/5a280d6a9154b1f13a0c74082581cb699825535fdc194ae9ebc5d42c2252/grimp-3.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79b0be412bc19bf298290713e5a2b459fe2a518f921591046b85ba90425bff20", size = 2627111, upload-time = "2026-09-04T11:27:19.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5a/5ef678b3f89a887a9e045dbfb5505876678323f8cf5a26f8b98d9ace875a/grimp-3.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c03a30879a80950e268f03e81603b8c51dc141c944700a0a9d72b2d7484ab24", size = 2347083, upload-time = "2026-09-04T11:27:28.976Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/06e46d5682745570d16c051734a7edf7b3c045ad620440f635dbd86447c3/grimp-3.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf26d99a35ab7b5966af56118bb9344d1fd1b972d3c45dd5012effc7a68c844", size = 2284554, upload-time = "2026-09-04T11:27:50.665Z" }, - { url = "https://files.pythonhosted.org/packages/eb/22/c00c7073a0fce1f9494c564e12705b42a1c421a50515dcf7ffd8e1543024/grimp-3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e205949a5fe6b83ac1ea28525bea2b4867f2b230a851e6dfcdfcfcee1e3dda09", size = 2455050, upload-time = "2026-09-04T11:28:18.529Z" }, - { url = "https://files.pythonhosted.org/packages/5b/23/f2b0bffa2a62a73ca8736b550161eab2b88f66e6a3c9f7def18310c2d087/grimp-3.17-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e2b4d8aebae7142c401231043113910773f289a8d6f9d719273eb719f732b83", size = 2496303, upload-time = "2026-09-04T11:28:28.147Z" }, - { url = "https://files.pythonhosted.org/packages/02/54/b4dfc51d8c628f700512953c78656305a21ead767956a7fd7aa46f1021b1/grimp-3.17-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5abd95dc0a8f816d2816e847adb0ab09a7e3571fd0942ea31cf5248505862bac", size = 2528584, upload-time = "2026-09-04T11:28:37.526Z" }, - { url = "https://files.pythonhosted.org/packages/b5/60/73c088e1bee1a95a61cc3523abff149b0c211405834ca74996c76e6a3afa/grimp-3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae6360f6b4f711abf97b8888c0269de3756ea484c829615e0c567c7bb1ce944", size = 2538538, upload-time = "2026-09-04T11:28:47.701Z" }, - { url = "https://files.pythonhosted.org/packages/9d/66/0ff96f79bb1c4ffff621f7b50babbea6151fbaad461327af961f1fa2e49e/grimp-3.17-cp312-cp312-win32.whl", hash = "sha256:b1ab648732d03fa2ec4d7619d934409399bf827009c4c50f429c8d0bdd0709fb", size = 1874539, upload-time = "2026-09-04T11:29:20.342Z" }, - { url = "https://files.pythonhosted.org/packages/a7/72/e2544f4ba4f42f7732bdc13d60c344fd9da30eb083eb8cba2e7c155d222e/grimp-3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d10ae67a1ee0af6dee937e032f3acf707741d866a67f3c71e5ec6df3b758e5c0", size = 1999174, upload-time = "2026-09-04T11:29:09.19Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9d/9b2da1ff2951e13b636673814ec7df699660f892f284bf2f3d53e991283c/grimp-3.17-cp312-cp312-win_arm64.whl", hash = "sha256:ff161c9aed2ca3db9bc592157a36914ae8440d8d8f3c6986843dec11523cd700", size = 1923198, upload-time = "2026-09-04T11:28:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/10/7a/b1c76982a239347bfccd2ae95b9f79d3390cd14170f62e7eb822a17d786d/grimp-3.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:eb8f09da4302f05fc92fe7564f27731c0b43789d8893ae5e6be5df2b01668923", size = 2145452, upload-time = "2026-09-04T11:28:12.121Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ab/2a1a9574b391c0ee62cb33a633c0d377dce1b7d19d04a7ac5d5e6872d395/grimp-3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f11566bcf388db4c79f1c7f1051f6ef150f778306350bb7d86d0c9aab32d297", size = 2091654, upload-time = "2026-09-04T11:28:04.635Z" }, - { url = "https://files.pythonhosted.org/packages/e6/12/7dc086132ac10a2409f158f0b0d7cc528a2ebe0a4e95bca895afe7452fa6/grimp-3.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfda3e2ebb4e51beec4a3cb09bb771440f3192163107d9320e6d9ce8fe064e64", size = 2277300, upload-time = "2026-09-04T11:27:01.844Z" }, - { url = "https://files.pythonhosted.org/packages/5b/62/2e16a54580fa76fb7e64e8e8e2ebb899c709a764cb29149a88eb03f2eea7/grimp-3.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0ab1ab71d807fc6f56d82f1ab24acf2f2e0e5000e37b5540af9449e28c6830c", size = 2220417, upload-time = "2026-09-04T11:27:11.418Z" }, - { url = "https://files.pythonhosted.org/packages/73/c0/e1b0ad0d0b3d9dff996bad306542f3cf000cb86c368bfe3ac6a8bb1e2e2f/grimp-3.17-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:187b092664e882d10b9f2c3da99251d0aeb99186d407ed5d20f52005832045f6", size = 2370754, upload-time = "2026-09-04T11:27:39.995Z" }, - { url = "https://files.pythonhosted.org/packages/36/0f/015e48ad8029f788eaa3df71b66859b2b5dc63abb8d7a2014a2b5a581040/grimp-3.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75da770aeee97d26b202d7251fe7f6562e7f2793e348501b0a64ca5c50e881b5", size = 2628257, upload-time = "2026-09-04T11:27:20.491Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d3/66da78504a1731237ed8befd9c66fb91037a7b82816443ca772107acf8fe/grimp-3.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5cc64f891f71f538d3d032d6e7193f69c3a2e0ceac72c55114faee5aa161f9f", size = 2347569, upload-time = "2026-09-04T11:27:30.282Z" }, - { url = "https://files.pythonhosted.org/packages/cb/a2/6474d008fa49cc650d626798cb3b6e3fa7b0daaad24884fd6bb4288f6154/grimp-3.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dbf2d93b0ba7f6c1efba9f5b3e07a3da0a157392c650316a5c1478b0ef7e503", size = 2284227, upload-time = "2026-09-04T11:27:52.462Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/91cd378c08494fd0130e5cc2e54d60d12f53b21850e0dbccf668b6e73d91/grimp-3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a22b5dcbf55f1c348885e09cbe91e63baedf6ce0f89c60bb01a0475c7d0a90b", size = 2454655, upload-time = "2026-09-04T11:28:19.837Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d0/b582238247ce51857c7ac770c3be10203c992696f3dc9153f1a979c011dc/grimp-3.17-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:73aa9459a604554b1dc1618148312036679f3974bf8b0285588220307147060b", size = 2495852, upload-time = "2026-09-04T11:28:29.606Z" }, - { url = "https://files.pythonhosted.org/packages/8f/99/e0690b02be70f346aadfe2aa8bd7e496c773298ac58c0d0fa6376c1298b2/grimp-3.17-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c31660d30e79569faac05033637c1eef4595542f44ec2323af54e900030164a6", size = 2527887, upload-time = "2026-09-04T11:28:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/69/7a/33e41d9b08e63cf398d8194a9208b21788c76916b5ac6d90146e8e2ebf95/grimp-3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8f395c5ab2cf554504f46680b9269b769939ab5d7ea58f93e5c341e55b1affbd", size = 2539000, upload-time = "2026-09-04T11:28:49.037Z" }, - { url = "https://files.pythonhosted.org/packages/69/f5/7af1dd74b9608a85274414fc423a7162844492b198ece3fe31f4e1f88237/grimp-3.17-cp313-cp313-win32.whl", hash = "sha256:34369739ed293accbb674494ae932351516b316e8e85d1d333d4c51796af846a", size = 1874244, upload-time = "2026-09-04T11:29:21.693Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/af497adddfcf11f9bd4ab273625a49022bf5ba4f5fc389589f4e6edc67b9/grimp-3.17-cp313-cp313-win_amd64.whl", hash = "sha256:d21b859c50418bdb48b403a84450650fa7aab3102f3212d00d3c21c76a982167", size = 1998225, upload-time = "2026-09-04T11:29:10.5Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2a/07fd537997281049af2390fc8641de5ae063ae4972cb6fcec85873674595/grimp-3.17-cp313-cp313-win_arm64.whl", hash = "sha256:206cd9f7c757b97934f48ba2b8ec6d649725fd6314d6b182dd327b19b4f35707", size = 1922071, upload-time = "2026-09-04T11:28:58.34Z" }, - { url = "https://files.pythonhosted.org/packages/39/e0/e11ea5aeb62534c9706e17952d142aa844ce5659e2d66b4b8ba9377407d6/grimp-3.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0100ff6f08067c8eb8a77dd1433af5e173aea8dbf6a6b76f5372a3b49f2e0a43", size = 2161696, upload-time = "2026-09-11T10:36:49.104Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d6/ddf82f4428d8bdc54ced0db244cbf5d8e5638490c3ed6a8b9f5db55dfa51/grimp-3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:67ada40a5c1cf4321183c0b0cd0393658ccd04b845caed002ac81dc2fdef828e", size = 2107301, upload-time = "2026-09-11T10:36:46.459Z" }, - { url = "https://files.pythonhosted.org/packages/86/1a/1dd0d9d2751e8078547e1cb7131e13d9624a2ea60700d926b3d624c15825/grimp-3.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa385915a4c17948bfb25f77509cbbfb33795f321d1c5ca7cc602e34ed58e99", size = 2278433, upload-time = "2026-09-04T11:27:03.075Z" }, - { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6", size = 2221152, upload-time = "2026-09-04T11:27:12.642Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4f/907a130e87554d57be14c2a7cedeb818d0ef519064877297d19636735f6b/grimp-3.17-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb953c21beb6ac85a59b5b54128be7fdddd89396d34b3c5705cf59829a85b431", size = 2371546, upload-time = "2026-09-04T11:27:41.195Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/a3bf534aa7c582a47d8cfefab5fccb50e6f4dcd327ed6a8552d344a04ffe/grimp-3.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b904c25359f98e3f20771256d62329ed17f1e26a254a4f5c74bacc1f3bd21", size = 2628467, upload-time = "2026-09-04T11:27:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/32/d3/2b2ab7b3a4e2c17bb1e10ec37e7ee8eecce7f72b1bf6c8019dd439943884/grimp-3.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69ce8be301299f15ca99843e89991c240a231836cf45a11c6bfc54fb9fbeaa9", size = 2347520, upload-time = "2026-09-04T11:27:31.73Z" }, - { url = "https://files.pythonhosted.org/packages/72/da/6bc1f783a739afa51ffa2e235791487b5d09202501b25843ecae9833a3ff/grimp-3.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566411d1ac97e1f3a3f00a13ae33aebb9abda9787d3d9914f62f73b589d84fe2", size = 2284007, upload-time = "2026-09-04T11:27:53.889Z" }, - { url = "https://files.pythonhosted.org/packages/63/06/a08f1b16aec11f32bbc86f60e24986659ab4cfab79388c8d4b79f6a6723e/grimp-3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5f89948029f3c7339a20bab007977194d3dbdddb27edae5c74b948d8f13191c7", size = 2455301, upload-time = "2026-09-04T11:28:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c1/d55e9686f3797d1a2c6403d2ffec3020c67591c004304a414fe8d7a9755c/grimp-3.17-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5a895bc27105ada9b0e76a80bf9cc898409e5cc3689e73e64ca1cbd71dc87dce", size = 2497158, upload-time = "2026-09-04T11:28:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/7f/85/a2d2e70c4f272752c395cc35b3732d82ff92c55e229d7bbd6777fd9868ff/grimp-3.17-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e80b57a7d03f53e96eb0a5b0844340f99f17eede184042fb73e3ef010176ab35", size = 2528862, upload-time = "2026-09-04T11:28:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/cb/03/fc3a58d9b22b011c1c3377c1ecee127a5f642de9b20de32d3ad7213ab71a/grimp-3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c0cac76283679fe9c1fd761e63deb9bbc7ba46b32988113ecef4b13742f31b9", size = 2538240, upload-time = "2026-09-04T11:28:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/58/13/d7666cf03c79c275020bad40ea1df8597af7877083ef5a84b5d8b566029a/grimp-3.17-cp314-cp314-win32.whl", hash = "sha256:c8982f4fa1276e059665947156740a55a4e8e6b5a4d7f40730a7511afb608762", size = 1874930, upload-time = "2026-09-04T11:29:23.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/ce/abb9ccb50972351461065ca72cd0f528837a2f398621147dfaf1013698f0/grimp-3.17-cp314-cp314-win_amd64.whl", hash = "sha256:0b97dc29bdca523932bbbcb425fc3df3547cf970396b293bfa960b910f3932d1", size = 1999405, upload-time = "2026-09-04T11:29:11.832Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1d/93c12ff4ce9ede9f91aa03df66128d223a4c824a13de6ab702c973fcae0d/grimp-3.17-cp314-cp314-win_arm64.whl", hash = "sha256:80b5b25b8d8ed6cc68dc39ae9e4c2fb64acd1a3e66434d9cf4d68c72e46d505d", size = 1923324, upload-time = "2026-09-04T11:29:00.004Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/f94dd90757a2195415736f0e2be667ac4cd375fe86d875d4298661751fce/grimp-3.17-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e15340ee11e34bfaaee496cff53f8287dc9f417a94e5bdf3d6b4204305d004cc", size = 2145199, upload-time = "2026-09-04T11:28:13.287Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/9d7cb22fc136ad6a61bff3b74cf0b31b13fdf94e224d46ff1edd9b5b8c86/grimp-3.17-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbccf2a6cd3f89bfdfd683d9b000bd61f6936e16b6d4d6619da592c54f79f272", size = 2090761, upload-time = "2026-09-04T11:28:05.984Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6b/b70add79e9e315d111bb1643b44e1b9e1ded82888856e1ad68bc484cd635/grimp-3.17-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5332893955688759a9f7868c3c9e56b19bc1694ee251852d3daf54876b35c115", size = 2279005, upload-time = "2026-09-04T11:27:04.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/4c/f5f001e592f8e8820086604db98373ca129e2831facad81ec0d1820a749f/grimp-3.17-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:848a92d8a63a38d79c89ab1e68fb5429d90079f39c07533ffd45ce756e7f210f", size = 2220430, upload-time = "2026-09-04T11:27:13.814Z" }, - { url = "https://files.pythonhosted.org/packages/83/13/cc57724307c22060e193a1dc9bc99fa14d46584c534a3936319ec579c599/grimp-3.17-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c89cfe49f822a2cc9c42cc7b0f3975ecb1281970735ea31610bfa06a7e04c460", size = 2370318, upload-time = "2026-09-04T11:27:42.535Z" }, - { url = "https://files.pythonhosted.org/packages/50/1b/05d9964d4b47ffe0ef826bfe04b3f4b713fd623c90e424ff002cb0d8a917/grimp-3.17-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:541c9a2b7f1ad5d4f5d1597740e51058fba8ca0c1a225d6ccc6d11a8364fa563", size = 2629127, upload-time = "2026-09-04T11:27:23.18Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ea/186a3087623f1556eb8b9d02baa00300003bb84b7b35677e3fcec34691b6/grimp-3.17-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66b4a13edb3fe63be6f7dbbc2576786f501af9d386a5a1f60b2938f270aef97c", size = 2348453, upload-time = "2026-09-04T11:27:32.964Z" }, - { url = "https://files.pythonhosted.org/packages/e8/6c/a718328adc50529135510b515c5ff1d9f94ab26ab8df9057bb1ef5ab8993/grimp-3.17-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160abad142e168545be8d4cc91b2a95b4565f7571fc56f7fced69ce3589e5111", size = 2287338, upload-time = "2026-09-04T11:27:55.16Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/bf67bc6749abee0a1e97f8f14ee2cb19ccc435b306e22bcaac6118d402a4/grimp-3.17-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cd76fb13a534209c1777c807092f51b1570854eb49948d65cd5e1d94d80e981d", size = 2455299, upload-time = "2026-09-04T11:28:22.505Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/6c954092306e21c93c744432c3d47491c3264d3cc4b465941de5164a1cb3/grimp-3.17-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1688e2dbe382b6644ecc3f55053e319de70034d9dfe0809d21d441a590c320e6", size = 2496813, upload-time = "2026-09-04T11:28:32.303Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a8/cf0aa245ace55dd2f6bdd3bb646ec50c294bdfe623fd51327bd27efaa585/grimp-3.17-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0433d177f327c31410f21845322282c832d887f1a42cbdfa176133d8422d84b2", size = 2527052, upload-time = "2026-09-04T11:28:41.869Z" }, - { url = "https://files.pythonhosted.org/packages/75/b8/90cd11e346f12fb5269ff050ae825083410d3bdb54d82a3b8e955e4f9d26/grimp-3.17-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790d68157e034e7848257ef46b37550040c9e4692cea17372d3d9f87cef1d84b", size = 2541238, upload-time = "2026-09-04T11:28:51.786Z" }, - { url = "https://files.pythonhosted.org/packages/48/92/10cc37db6149030c8cfc80fa5287f739c1e9620c8a05d1ba8dfdfc0a6c4f/grimp-3.17-cp314-cp314t-win32.whl", hash = "sha256:37e6333ca54d4d80b5c4f2eae324774b223dbdc65994dc9bccda2985a9261dd8", size = 1872673, upload-time = "2026-09-04T11:29:24.514Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cc/b257d8da079a87e65d0f1b0e004fd50f31ba25e7136a3b36f896e0e338f8/grimp-3.17-cp314-cp314t-win_amd64.whl", hash = "sha256:91e4bbbed4d934369b83071380e1ad5f37b7f5c80b80ffa924627db8d66fea11", size = 2000197, upload-time = "2026-09-04T11:29:13.138Z" }, - { url = "https://files.pythonhosted.org/packages/29/d0/feebddf766448932f2f1e40324fe75cf50ce6d3a21bdf87fc0eae263218a/grimp-3.17-cp314-cp314t-win_arm64.whl", hash = "sha256:28ae2ff57a9ef535c2e7054dcb18baf0cdc2dee3982367512bff34eaecb85cf0", size = 1920856, upload-time = "2026-09-04T11:29:01.498Z" }, - { url = "https://files.pythonhosted.org/packages/83/39/bb07084f0b97da66b0aa38077bd656c4f831b4aa3f167b1c060f6a65ea5e/grimp-3.17-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe3e2285a2bd4e62ddb8825de37152d2dc842394d419786d3d5a803b07bb84eb", size = 2372594, upload-time = "2026-09-04T11:27:43.884Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a9/905a484659749f40ed797963147202cb74c3b68d8e90a7a600af55de47ee/grimp-3.17-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a377d5935ae9a62ccbb59e492e16624cec97cc7244c4a964b0c6f83940440626", size = 2284577, upload-time = "2026-09-04T11:27:56.428Z" }, - { url = "https://files.pythonhosted.org/packages/30/6a/40fb12b111c95b94db4930f35661a629532d9d12fb484db5b5ef1a6c3da3/grimp-3.17-cp315-cp315-win32.whl", hash = "sha256:65a1dc2741bef8a96ff3ff71252db8e99eac2a32e09d5e2b63597474706186ca", size = 1876437, upload-time = "2026-09-04T11:29:25.725Z" }, - { url = "https://files.pythonhosted.org/packages/71/98/79af8d51961611e13b3dabc7a3b356b27aa696e5549bb439f1d6002be31a/grimp-3.17-cp315-cp315-win_amd64.whl", hash = "sha256:d20fbe5aab8b013b351313f8eaa3a46f4ac57677e357cb1c3d1012c606b565b4", size = 1999947, upload-time = "2026-09-04T11:29:14.623Z" }, - { url = "https://files.pythonhosted.org/packages/96/d6/e1922c4c7e001210f98e0cf89bd76fea8564f590be3e9ccfe99435cfcd59/grimp-3.17-cp315-cp315-win_arm64.whl", hash = "sha256:a0288dc0e8947dead5c02a246e16f3f5e5cb486348171bdf1289885a13de36c1", size = 1923774, upload-time = "2026-09-04T11:29:02.897Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/f4f84e44a9237350c41e7c77343498cb1ad843491c18f29d6811021c617d/grimp-3.17-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:1bf67cb05a80796be99d1bf87e12d4421de101950c5246a8220b6c839cbf27c2", size = 2145245, upload-time = "2026-09-04T11:28:14.537Z" }, - { url = "https://files.pythonhosted.org/packages/71/98/822287270c41360f66c4b5638e040afc6bbf11e9f04cf5005bbd5f3e4830/grimp-3.17-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d058fdb980475eb9e0143792d63757ddff86c5776e792866e8208cfe5e8413f9", size = 2090367, upload-time = "2026-09-04T11:28:07.277Z" }, - { url = "https://files.pythonhosted.org/packages/ac/76/32d303603157a0d98cb8bde8757e6ae8fa02d41f1d62cdf843fb2e7fe727/grimp-3.17-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92f1989ce9f9ce26b7d45381df164c62aff783add84cd7a4216a6ebba0f9f03a", size = 2371543, upload-time = "2026-09-04T11:27:45.108Z" }, - { url = "https://files.pythonhosted.org/packages/7b/68/4551e23ab9422e2ead806c1fec9f4b3acd21a267e43bfe8e8cd7f4640dce/grimp-3.17-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e275cee07826cf36db1914427e68afac87588f759e1dec676870234d4a250ae", size = 2288003, upload-time = "2026-09-04T11:27:57.605Z" }, - { url = "https://files.pythonhosted.org/packages/20/64/79161f05c900f7f7f1b0acd22a730b1be9f96680dd44efeef90aa45e6998/grimp-3.17-cp315-cp315t-win32.whl", hash = "sha256:a7235ccffb0350647ae2b01969f552a9859fea7027032f6cfed110801269a439", size = 1873249, upload-time = "2026-09-04T11:29:27.094Z" }, - { url = "https://files.pythonhosted.org/packages/94/b7/6f9cdeba2c1263fb91b791ddd5e924d6c91c2485f4f16b56698802fdd540/grimp-3.17-cp315-cp315t-win_amd64.whl", hash = "sha256:473d0e0c016ca429b9ec17f34d276db454ba4d467e3c915a46279d86ab004716", size = 2000579, upload-time = "2026-09-04T11:29:15.961Z" }, - { url = "https://files.pythonhosted.org/packages/ac/16/8ae9028f046bf8f48cc13f6de2de40adbcbf38fa79a3a5f265753b248da0/grimp-3.17-cp315-cp315t-win_arm64.whl", hash = "sha256:478aa0b696e5dd79373943e75b998fbb4ef3d146f24fe3569fa9d106ce84991b", size = 1921829, upload-time = "2026-09-04T11:29:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/6615a81f83d8ee31c96b1d8fa9a5d12fceb82acc17a9e71b28768b8185b5/grimp-3.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b1517920dd93df17f6cf0bb79a1ddf193993e87a60dec30bbe67a5139187f0" }, + { url = "https://files.pythonhosted.org/packages/73/52/5a280d6a9154b1f13a0c74082581cb699825535fdc194ae9ebc5d42c2252/grimp-3.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79b0be412bc19bf298290713e5a2b459fe2a518f921591046b85ba90425bff20" }, + { url = "https://files.pythonhosted.org/packages/ab/5a/5ef678b3f89a887a9e045dbfb5505876678323f8cf5a26f8b98d9ace875a/grimp-3.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c03a30879a80950e268f03e81603b8c51dc141c944700a0a9d72b2d7484ab24" }, + { url = "https://files.pythonhosted.org/packages/1e/77/06e46d5682745570d16c051734a7edf7b3c045ad620440f635dbd86447c3/grimp-3.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf26d99a35ab7b5966af56118bb9344d1fd1b972d3c45dd5012effc7a68c844" }, + { url = "https://files.pythonhosted.org/packages/5b/23/f2b0bffa2a62a73ca8736b550161eab2b88f66e6a3c9f7def18310c2d087/grimp-3.17-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e2b4d8aebae7142c401231043113910773f289a8d6f9d719273eb719f732b83" }, + { url = "https://files.pythonhosted.org/packages/b5/60/73c088e1bee1a95a61cc3523abff149b0c211405834ca74996c76e6a3afa/grimp-3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae6360f6b4f711abf97b8888c0269de3756ea484c829615e0c567c7bb1ce944" }, + { url = "https://files.pythonhosted.org/packages/5b/62/2e16a54580fa76fb7e64e8e8e2ebb899c709a764cb29149a88eb03f2eea7/grimp-3.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0ab1ab71d807fc6f56d82f1ab24acf2f2e0e5000e37b5540af9449e28c6830c" }, + { url = "https://files.pythonhosted.org/packages/36/0f/015e48ad8029f788eaa3df71b66859b2b5dc63abb8d7a2014a2b5a581040/grimp-3.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75da770aeee97d26b202d7251fe7f6562e7f2793e348501b0a64ca5c50e881b5" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/66da78504a1731237ed8befd9c66fb91037a7b82816443ca772107acf8fe/grimp-3.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5cc64f891f71f538d3d032d6e7193f69c3a2e0ceac72c55114faee5aa161f9f" }, + { url = "https://files.pythonhosted.org/packages/cb/a2/6474d008fa49cc650d626798cb3b6e3fa7b0daaad24884fd6bb4288f6154/grimp-3.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dbf2d93b0ba7f6c1efba9f5b3e07a3da0a157392c650316a5c1478b0ef7e503" }, + { url = "https://files.pythonhosted.org/packages/f1/d0/b582238247ce51857c7ac770c3be10203c992696f3dc9153f1a979c011dc/grimp-3.17-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:73aa9459a604554b1dc1618148312036679f3974bf8b0285588220307147060b" }, + { url = "https://files.pythonhosted.org/packages/69/7a/33e41d9b08e63cf398d8194a9208b21788c76916b5ac6d90146e8e2ebf95/grimp-3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8f395c5ab2cf554504f46680b9269b769939ab5d7ea58f93e5c341e55b1affbd" }, + { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/a3bf534aa7c582a47d8cfefab5fccb50e6f4dcd327ed6a8552d344a04ffe/grimp-3.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b904c25359f98e3f20771256d62329ed17f1e26a254a4f5c74bacc1f3bd21" }, + { url = "https://files.pythonhosted.org/packages/32/d3/2b2ab7b3a4e2c17bb1e10ec37e7ee8eecce7f72b1bf6c8019dd439943884/grimp-3.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69ce8be301299f15ca99843e89991c240a231836cf45a11c6bfc54fb9fbeaa9" }, + { url = "https://files.pythonhosted.org/packages/72/da/6bc1f783a739afa51ffa2e235791487b5d09202501b25843ecae9833a3ff/grimp-3.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566411d1ac97e1f3a3f00a13ae33aebb9abda9787d3d9914f62f73b589d84fe2" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/d55e9686f3797d1a2c6403d2ffec3020c67591c004304a414fe8d7a9755c/grimp-3.17-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5a895bc27105ada9b0e76a80bf9cc898409e5cc3689e73e64ca1cbd71dc87dce" }, + { url = "https://files.pythonhosted.org/packages/cb/03/fc3a58d9b22b011c1c3377c1ecee127a5f642de9b20de32d3ad7213ab71a/grimp-3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c0cac76283679fe9c1fd761e63deb9bbc7ba46b32988113ecef4b13742f31b9" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f5f001e592f8e8820086604db98373ca129e2831facad81ec0d1820a749f/grimp-3.17-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:848a92d8a63a38d79c89ab1e68fb5429d90079f39c07533ffd45ce756e7f210f" }, + { url = "https://files.pythonhosted.org/packages/50/1b/05d9964d4b47ffe0ef826bfe04b3f4b713fd623c90e424ff002cb0d8a917/grimp-3.17-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:541c9a2b7f1ad5d4f5d1597740e51058fba8ca0c1a225d6ccc6d11a8364fa563" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/186a3087623f1556eb8b9d02baa00300003bb84b7b35677e3fcec34691b6/grimp-3.17-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66b4a13edb3fe63be6f7dbbc2576786f501af9d386a5a1f60b2938f270aef97c" }, + { url = "https://files.pythonhosted.org/packages/e8/6c/a718328adc50529135510b515c5ff1d9f94ab26ab8df9057bb1ef5ab8993/grimp-3.17-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160abad142e168545be8d4cc91b2a95b4565f7571fc56f7fced69ce3589e5111" }, + { url = "https://files.pythonhosted.org/packages/83/8a/6c954092306e21c93c744432c3d47491c3264d3cc4b465941de5164a1cb3/grimp-3.17-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1688e2dbe382b6644ecc3f55053e319de70034d9dfe0809d21d441a590c320e6" }, + { url = "https://files.pythonhosted.org/packages/75/b8/90cd11e346f12fb5269ff050ae825083410d3bdb54d82a3b8e955e4f9d26/grimp-3.17-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790d68157e034e7848257ef46b37550040c9e4692cea17372d3d9f87cef1d84b" }, + { url = "https://files.pythonhosted.org/packages/e6/a9/905a484659749f40ed797963147202cb74c3b68d8e90a7a600af55de47ee/grimp-3.17-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a377d5935ae9a62ccbb59e492e16624cec97cc7244c4a964b0c6f83940440626" }, + { url = "https://files.pythonhosted.org/packages/7b/68/4551e23ab9422e2ead806c1fec9f4b3acd21a267e43bfe8e8cd7f4640dce/grimp-3.17-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e275cee07826cf36db1914427e68afac87588f759e1dec676870234d4a250ae" }, +] + +[[package]] +name = "hydra-core" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "omegaconf", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "packaging", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/a9/fe2c936f36881e3197a8800f5bfba078d7846d54eb5d009b445eda1ee281/hydra_core-1.3.7.tar.gz", hash = "sha256:d4ff629c7e23b64a9db5fe5b027cf7c55b633b737f03be51ef41b77ed016f75e" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0c/21890c10cc94da917c8e47efc1c55cd3e56a1d25ab5947bdd35abaf9fdc1/hydra_core-1.3.7-py3-none-any.whl", hash = "sha256:83606c4a05c770f1e026e0489875f28989829d151799eabd8cfa8cbe82164515" }, ] [[package]] name = "identify" version = "2.6.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa" }, ] [[package]] @@ -204,23 +328,103 @@ name = "import-linter" version = "2.15" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "grimp" }, - { name = "rich" }, - { name = "typing-extensions" }, + { name = "click", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "grimp", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "rich", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590", size = 1405973, upload-time = "2026-09-04T14:47:56.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b", size = 640512, upload-time = "2026-09-04T14:47:54.878Z" }, + { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b" }, ] [[package]] name = "iniconfig" version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" }, +] + +[[package]] +name = "iopath" +version = "0.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "portalocker", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "tqdm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/b3d451dfc523756cf177d3ebb0af76dc7751b341c60e2a21871be400ae29/iopath-0.1.10.tar.gz", hash = "sha256:3311c16a4d9137223e20f141655759933e1eda24f8bff166af834af3c645ef01" } + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444" }, + { url = "https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d" }, + { url = "https://files.pythonhosted.org/packages/61/a1/fa71c1792272ff9461432715ae60ffb7e11a4d7ac3bf68961b9cab6c60cf/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3f0bd94af8e06ecc47eb834b195a06f05d48711ceb2352c56d6835160f0e/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89" }, + { url = "https://files.pythonhosted.org/packages/a4/44/3afe6ef9cf06d61220953a8963e94eca978491be1d9547cb01d82a1efa08/kiwisolver-1.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/233e2958abf0dab7b18d07e52f286e02e519d7651bfbbe97af9347564109/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8" }, + { url = "https://files.pythonhosted.org/packages/42/8e/7673060a27b01405b580058510adef34687069d229800239f5e44682d4d0/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/1b882fc1a8b4a0bb8084e7d1d85004116d08c92c0705d31f2928dec607f2/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127" }, + { url = "https://files.pythonhosted.org/packages/39/9c/426deb49e62c5f69464b64bbeca064d3b758a7506b8913d986ef34f4619c/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b" }, + { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208" }, + { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab" }, + { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a" }, + { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85" }, + { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483" }, + { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6" }, + { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa" }, + { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0" }, + { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85" }, + { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c" }, + { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88" }, + { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408" }, + { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567" }, + { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b" }, + { url = "https://files.pythonhosted.org/packages/89/b1/ba7b9c0164ce1cf62bf2872db63b8483289cf0f3110d6f9390eb09e409ed/kiwisolver-1.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b" }, + { url = "https://files.pythonhosted.org/packages/5a/3a/30ffb62bee646e266e98a1b5cd276d9c75b6116fbfcb87c1190838c1b6df/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63" }, + { url = "https://files.pythonhosted.org/packages/a9/8d/13c70be22a8506880b35fdc38dca36629613bc493405c79f4037f2cd2bb9/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75" }, + { url = "https://files.pythonhosted.org/packages/82/0e/993972b8ec6767f47cd69818fb3a5ff14510557d29f7d1a839be7574fa1b/kiwisolver-1.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a" }, + { url = "https://files.pythonhosted.org/packages/c7/84/97d920881e10840b8d7c7185620298e3e4c88820b05514e3a15a258b08a6/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46" }, + { url = "https://files.pythonhosted.org/packages/e2/ce/34d74b8f25acc58800f4c09268371e8d6159cf0f1206f1e4dc7835629b48/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0" }, + { url = "https://files.pythonhosted.org/packages/0e/01/f892644014612527aef7031d3306a2ffc60b3cb044f802c1561f8e5e14f3/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951" }, + { url = "https://files.pythonhosted.org/packages/32/6d/d8284e66e697026536e5f418b9cfe56567bffd3c775e3ecfbae373605854/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/51dc974d9130da70a8c47a96160123443d387ffe1b6b833d6f91d9429339/kiwisolver-1.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/8ba25a2b5a0d2375e046f1b72de5179513f0be95aba6e7b094c89303929f/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c" }, + { url = "https://files.pythonhosted.org/packages/18/d0/278d5cb8be812740027d5ca0a7eda0c375488a88d6dce0fa60fcc2591ad2/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed" }, + { url = "https://files.pythonhosted.org/packages/94/37/bcbab41063ec284c1d200efe5087cf087798c2f8916960aa8a20dd303290/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/71d047468a189041d9c93f3b76844b924f9793b188c44bd149fa258912da/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e" }, + { url = "https://files.pythonhosted.org/packages/7d/37/1347461bbea6d0e1f0580b94ef603b18e72c2be5f667fa1653867361a00b/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f" }, + { url = "https://files.pythonhosted.org/packages/1f/87/c7f0976c9cd0d127643351bc0c9929e0b8899d7f49d4ec238cd909e39c42/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/59a6f6ae6f938c15076be2c21b6cedea973d71bb1349ec84fa485fab82cf/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0" }, ] [[package]] @@ -228,102 +432,361 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "cycler", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "fonttools", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "kiwisolver", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "packaging", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pillow", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pyparsing", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "python-dateutil", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2" }, + { url = "https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539" }, + { url = "https://files.pythonhosted.org/packages/a3/80/d72f61bb2631ac15d17fada1f17e0f99fecba69d8376d34f17cf93b7eddd/matplotlib-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e5a90f8a707ebb6004a713b1cff091a40cc5df4c7c1cb165e5a505ebc11c292" }, + { url = "https://files.pythonhosted.org/packages/29/5e/52f56f93d5b20815ee0ae352f7a38eff25580edb180b4fa7df87cfeba68f/matplotlib-3.11.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c8255de28f986d935a64c9ca71c0ec2d2f41d355691f5ea684725dc91413f71" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f8918a1b564db49c53cd603eb3c8748c5767e4ce0fb2cf1d7c38653e4c5d/matplotlib-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8756cc73d9af9a7fe0deb54ea2e75ef73b01d9e575877e72acad5458e660943" }, + { url = "https://files.pythonhosted.org/packages/45/46/396e0307dbf1c1f2d0a3654caa6dbc780dd5379f5f11257369cf8d21d9fe/matplotlib-3.11.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3304eb5a59442a8867f6920d484591c0fa09ffc29e9260be2feec3351e25869" }, + { url = "https://files.pythonhosted.org/packages/1c/97/4defb84695477340aa88b27c9482cc1ca4de11f36f66b6c8c0ddd685def1/matplotlib-3.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02329432ae5c6af87cf208ee575b701d698bdf0b1a3bb28cc6d53c36e967e575" }, + { url = "https://files.pythonhosted.org/packages/a1/97/d7e90225ac145046787a2ee0fa4cdc7e4d5cb6772ea232130b10f8a4095a/matplotlib-3.11.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc82dde2a0d3e3ad472edce04897ad7146b8d8bfd1df8a32992eebb81af18fdc" }, + { url = "https://files.pythonhosted.org/packages/58/18/89cf056a9d2cae01f4b8baf01c7e809d613244fcdfa2a2acf28c1e0d79fa/matplotlib-3.11.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:930efb28f59fda124e39265d177bab302625297bb147d2910de725a2fc2aef54" }, + { url = "https://files.pythonhosted.org/packages/af/dd/b3b19ec0124fc4aafd8a4b3bae859c562a7ff0fd8ae3b2099947f78bde19/matplotlib-3.11.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3712dc9b464793de0a4e42a7313d50293c751f94bddaf7332a1bc71bccdda9" }, + { url = "https://files.pythonhosted.org/packages/7b/30/687ccce22f66a57fed85942450e88062773bcbf1a42914e70c7148ec71dc/matplotlib-3.11.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:75b6d88402770e181b5d06a67dda4129c31da7d05004d63a21c310ec78d1b83c" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "medsam2" +version = "1.0" +source = { editable = "reference/MedSAM2" } +dependencies = [ + { name = "hydra-core", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "iopath", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pillow", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "simpleitk", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "tqdm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] + +[package.metadata] +requires-dist = [ + { name = "av", marker = "extra == 'interactive-demo'", specifier = ">=13.0.0" }, + { name = "black", marker = "extra == 'dev'", specifier = "==24.2.0" }, + { name = "dataclasses-json", marker = "extra == 'interactive-demo'", specifier = ">=0.6.7" }, + { name = "eva-decord", marker = "extra == 'interactive-demo'", specifier = ">=0.6.1" }, + { name = "eva-decord", marker = "extra == 'notebooks'", specifier = ">=0.6.1" }, + { name = "flask", marker = "extra == 'interactive-demo'", specifier = ">=3.0.3" }, + { name = "flask-cors", marker = "extra == 'interactive-demo'", specifier = ">=5.0.0" }, + { name = "fvcore", marker = "extra == 'dev'", specifier = ">=0.1.5.post20221221" }, + { name = "gunicorn", marker = "extra == 'interactive-demo'", specifier = ">=23.0.0" }, + { name = "hydra-core", specifier = ">=1.3.2" }, + { name = "imagesize", marker = "extra == 'interactive-demo'", specifier = ">=1.4.1" }, + { name = "iopath", specifier = ">=0.1.10" }, + { name = "jupyter", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "jupyter", marker = "extra == 'notebooks'", specifier = ">=1.0.0" }, + { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.9.1" }, + { name = "matplotlib", marker = "extra == 'notebooks'", specifier = ">=3.9.1" }, + { name = "numpy", specifier = ">=2.0.1" }, + { name = "opencv-python", marker = "extra == 'dev'", specifier = ">=4.10.0" }, + { name = "opencv-python", marker = "extra == 'notebooks'", specifier = ">=4.10.0" }, + { name = "pandas", marker = "extra == 'dev'", specifier = ">=2.2.3" }, + { name = "pillow", specifier = ">=10.4.0" }, + { name = "pycocotools", marker = "extra == 'dev'", specifier = ">=2.0.8" }, + { name = "pycocotools", marker = "extra == 'interactive-demo'", specifier = ">=2.0.8" }, + { name = "scikit-image", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "simpleitk", specifier = ">=2.4.0" }, + { name = "strawberry-graphql", marker = "extra == 'interactive-demo'", specifier = ">=0.239.2" }, + { name = "submitit", marker = "extra == 'dev'", specifier = ">=1.5.1" }, + { name = "tensorboard", marker = "extra == 'dev'", specifier = ">=2.17.0" }, + { name = "tensordict", marker = "extra == 'dev'", specifier = ">=0.5.0" }, + { name = "torch", specifier = ">=2.5.1" }, + { name = "torchvision", specifier = ">=0.20.1" }, + { name = "tqdm", specifier = ">=4.66.5" }, + { name = "ufmt", marker = "extra == 'dev'", specifier = "==2.0.0b2" }, + { name = "usort", marker = "extra == 'dev'", specifier = "==1.0.2" }, +] +provides-extras = ["notebooks", "interactive-demo", "dev"] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, +] + +[[package]] +name = "networkx" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0" }, ] [[package]] name = "nodeenv" version = "1.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146" } 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" }, + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25" }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a" } 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" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.24.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/ba/791cffd048fe5b044e620df55267e3e95c0e6e07d50b41e377c03dfc910f/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.30.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/34/c500f90c7ae641b8e0f98965b36b8a7ac79cc8b296e8d251fe3eb592ee54/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.4.92" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6b/eef7a9e32872b8f41e145bf10cddc9af26e153c338852811fe9a9baddf9e/nvidia_nvjitlink-13.4.92-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e0391f24ed94ec879b84e3da4d4ec320c879aff681f2c7a638462f7199284323" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pyyaml", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0" }, ] [[package]] @@ -331,18 +794,12 @@ name = "opencv-python-headless" version = "5.0.0.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c" } 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" }, + { 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" }, + { 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" }, ] [[package]] @@ -350,109 +807,67 @@ name = "openpyxl" version = "3.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "et-xmlfile" }, + { name = "et-xmlfile", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2" }, ] [[package]] name = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" }, ] [[package]] name = "pillow" version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9" }, ] [[package]] name = "platformdirs" -version = "4.13.3" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/91/4a03cfdb03314cfca262921797fff04ff4054bd86a160aed76eddf8aa12b/platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce", size = 61559, upload-time = "2026-10-16T01:16:16.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/b8/fd1af06b079af236f5423f7c1821264419cc8f6b4803f79353acbb8bfa53/platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4", size = 32762, upload-time = "2026-10-16T01:16:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1" }, ] [[package]] name = "pluggy" version = "1.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" }, +] + +[[package]] +name = "portalocker" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/cd/d2a23fc80c26f539ac77e7d61bd5e4af5d0b3a09a78ac4c716eead345129/portalocker-4.4.0.tar.gz", hash = "sha256:90c0df939d4ffba121f8e925bbf98ecea8b9381718666ab871a226938d2b63b2" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/6c/07fa2775e87b6e241c2aca5cef79f65e450bc115de55f03cda6251f81690/portalocker-4.4.0-py3-none-any.whl", hash = "sha256:a8e99ea29bfb61766ee0b4008cbaf0651e8e050f7a2485ebf54316480e99226e" }, ] [[package]] @@ -460,15 +875,15 @@ name = "pre-commit" version = "4.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, + { name = "cfgv", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "identify", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nodeenv", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pyyaml", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "virtualenv", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz", hash = "sha256:1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86", size = 198120, upload-time = "2026-10-12T20:45:40.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz", hash = "sha256:1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877", size = 226261, upload-time = "2026-10-12T20:45:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877" }, ] [[package]] @@ -476,14 +891,14 @@ name = "pydantic" version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pydantic-core", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-inspection", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454" }, ] [[package]] @@ -491,114 +906,71 @@ name = "pydantic-core" version = "2.50.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, - { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, - { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, - { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, - { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, - { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, - { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, - { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, - { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, - { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, - { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, - { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, - { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, - { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, - { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, - { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, - { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, - { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, - { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, - { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, - { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, - { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, - { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, - { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, - { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, - { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, - { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, - { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, - { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, - { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, - { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, - { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, - { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, - { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, - { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, - { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, - { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, - { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, - { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, - { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, - { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, - { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, - { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, - { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, - { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, - { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, - { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, - { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, - { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, - { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, - { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, - { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, - { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, - { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, - { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, - { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, - { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, - { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61" }, ] [[package]] name = "pygments" version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4" }, ] [[package]] @@ -606,12 +978,12 @@ name = "pyright" version = "1.1.414" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, + { name = "nodeenv", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f" }, ] [[package]] @@ -619,15 +991,26 @@ name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "iniconfig", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "packaging", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pluggy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pygments", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, ] [[package]] @@ -635,57 +1018,31 @@ name = "python-discovery" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, + { name = "filelock", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/73/54993df8fc57e906dc9b7e01d1d9b0b2d3eb0ce3190639e33dccb12853f7/python_discovery-1.6.2.tar.gz", hash = "sha256:cd1738ca1d37c86ef9d0b654dd46fcee1e41c97c6add12575e8501ced39afdb4", size = 86921, upload-time = "2026-10-10T01:24:27.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/73/54993df8fc57e906dc9b7e01d1d9b0b2d3eb0ce3190639e33dccb12853f7/python_discovery-1.6.2.tar.gz", hash = "sha256:cd1738ca1d37c86ef9d0b654dd46fcee1e41c97c6add12575e8501ced39afdb4" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl", hash = "sha256:f0c697f95a3aaec4174a6e5e58f5885e25768684bafc76f1afde384709ebdcf2", size = 38912, upload-time = "2026-10-10T01:24:26.721Z" }, + { url = "https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl", hash = "sha256:f0c697f95a3aaec4174a6e5e58f5885e25768684bafc76f1afde384709ebdcf2" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, ] [[package]] @@ -693,64 +1050,180 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pygments", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, ] [[package]] name = "ruff" version = "0.17.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, - { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, - { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, - { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, - { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, - { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" }, +] + +[[package]] +name = "simpleitk" +version = "2.5.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ec/301532fb2003e6557e6a12106eb1df572ed6f74c08c05c2e7a8913353383/simpleitk-2.5.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99242c333ed17138134e9749f3a484518f41baebab0fc0f0fe7c657ab8090c07" }, + { url = "https://files.pythonhosted.org/packages/9f/22/8944f38bf222a63b74605aa9f0e2fb1c807155e5d71aee749383428b2e52/simpleitk-2.5.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baf0d5731b8918efce8084e5e28e403aab116e1081b77822123bb55e0841e5e7" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, +] + +[[package]] +name = "torch" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "python_full_version < '3.15' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "filelock", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "fsspec", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "jinja2", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "networkx", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "sympy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "triton", marker = "python_full_version < '3.15' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/fd/c470857d0dfc5d4c94430658b3eed60fd5292b0bf596a8aae7b9fd66d674/torch-2.14.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:23011fe29a99b591eabb3a31a25080c62e2c2f0690a3c798744e5489dae50451" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bb579ac0c80e077c58204e43b37a254b74dea5873635100bea25789a55d4/torch-2.14.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c8f71aabc67bcbfc9373dc131537a5968d04edce73e88add21354a7cd0a76985" }, + { url = "https://files.pythonhosted.org/packages/fd/df/23c69e9b9fd19fe6563422f1bc59f89601bda47234a89a1cf432cfde3aee/torch-2.14.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1d4df07be7338bbcc4d54085adee21363c91314702b9bd4d78ef72ffac9465ba" }, + { url = "https://files.pythonhosted.org/packages/03/d8/8272157c438cc26a199a8fa0caf87985477b8a1ed8a09084eb2ae40609d8/torch-2.14.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e65d5fe136e533b23c2d134377f7f126721c7c78dd75b3dbb735af082a8aeb85" }, +] + +[[package]] +name = "torchvision" +version = "0.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "pillow", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/6f/78267c9791b5b883421fe4dbba6041f06b59c01d43dbe0f57b4497a74f59/torchvision-0.29.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6c717bd68dddaebbd9525377a17a84e38a291a33a218f59fdb76e8bb41e61c99" }, + { url = "https://files.pythonhosted.org/packages/f6/b5/915c431a48d7f91cc31bf825aee98e663440386758131668da9b9862aafd/torchvision-0.29.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c1b7f92604791dd0daa566f6fcc1eea0801753a34430c3ebdf79cfab2d96904c" }, + { url = "https://files.pythonhosted.org/packages/5f/80/4bd680ea6cc992f39d92f952daf6bf26579d3ee02b0bd050c8b771f53c4a/torchvision-0.29.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7e525b2fb6ba6575223e7e3b64bb4c3992124f3f4083c3097127bbce5098c237" }, + { url = "https://files.pythonhosted.org/packages/a8/47/c9f97d761b678c09ebfc0daf4bb6093293817dac79487f32996de9afbc19/torchvision-0.29.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8ce8d1baa17da6d681ae35128148624287dcf3ea947116ec07520f860828f4ff" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73" }, +] + +[[package]] +name = "triton" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/09/b7012e5bfae67640f268aa584caa80fe1674f6b0da949046b679972c33e3/triton-3.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91ffa46d095b252248297292dd22bcbacd53a125a0c2eefbbbf74925a320bc3" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/3394d5548404c1cabd1dadadd28d0b3f9478db1dff8180da53bb3f0a1e19/triton-3.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0497218e26b7d79773ad9c2a3fa3b539ee69f587a13fac2e552b1d322a8015" }, + { url = "https://files.pythonhosted.org/packages/93/d9/08c75f3459f19ad00425b564058e40efa4bcd79b816064cf27499303ea42/triton-3.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:387dae4cb0089a7b6ba1a428ae0782b65c4c58f57d94617cb22ca8593d8ccbca" }, + { url = "https://files.pythonhosted.org/packages/fe/d1/aa8a3e935c37efee7945984fdb64d7e0851bf6d920afd97b2d21f9d23360/triton-3.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74217bb56ed8692759227758e4c4b3bd2d608a209c1a7a081bf361fb4c2c1bf9" }, ] [[package]] name = "types-openpyxl" version = "3.1.5.20260827" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/6b/ce650ce7754a2bce3ca1dfbce7f7441df092e2dc6047d00e04f840c2b56e/types_openpyxl-3.1.5.20260827.tar.gz", hash = "sha256:be8b605fb99cfd7d5f5576d4a508e8ec44be2dd15b85157c559080de6384be34", size = 101985, upload-time = "2026-08-27T12:06:18.927Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/6b/ce650ce7754a2bce3ca1dfbce7f7441df092e2dc6047d00e04f840c2b56e/types_openpyxl-3.1.5.20260827.tar.gz", hash = "sha256:be8b605fb99cfd7d5f5576d4a508e8ec44be2dd15b85157c559080de6384be34" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/23/9708c0895d237205ab2b31c06f97d72294f69789ff9923ff7f4aa2126d6b/types_openpyxl-3.1.5.20260827-py3-none-any.whl", hash = "sha256:94e176d871d12e3cbc34f8fb03dc14db2a4245a6690791daf16fc7b08fd67869", size = 165885, upload-time = "2026-08-27T12:06:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/12/23/9708c0895d237205ab2b31c06f97d72294f69789ff9923ff7f4aa2126d6b/types_openpyxl-3.1.5.20260827-py3-none-any.whl", hash = "sha256:94e176d871d12e3cbc34f8fb03dc14db2a4245a6690791daf16fc7b08fd67869" }, ] [[package]] name = "types-pyyaml" version = "6.0.12.20260906" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" }, ] [[package]] @@ -758,25 +1231,25 @@ name = "typing-inspection" version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" }, ] [[package]] name = "virtualenv" -version = "21.14.8" +version = "21.14.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "packaging" }, - { name = "platformdirs" }, - { name = "python-discovery" }, + { name = "distlib", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "filelock", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "packaging", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "platformdirs", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "python-discovery", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/6a/fbdeea5c07d5674139639d42a97a41d26e9b9b8cb2a1265fa936f655ee20/virtualenv-21.14.8.tar.gz", hash = "sha256:394e45ed610ceffb5018e1a179e3a8012c02fd87fe792ab4aae67ce5e7e3afbd", size = 5474561, upload-time = "2026-10-16T00:09:09.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/3f/935cf1757643381fb7f7cc98197b4bc119223d9c2cf37dc3b03ad2a4455f/virtualenv-21.14.7.tar.gz", hash = "sha256:5f427d56f39eb7e7447d641dd4b7ab1e9c92793f498f35f26da2e4972c5546ae" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/01/cd63b97dd556293e1c74d76053936d9cef11a28eac605c735ae9f0ee324f/virtualenv-21.14.8-py3-none-any.whl", hash = "sha256:d2a347fdbc1a65e33c2c5e84dc767b4a3538a25749536a752744d4fe1f71c37a", size = 5487606, upload-time = "2026-10-16T00:09:07.441Z" }, + { url = "https://files.pythonhosted.org/packages/52/5b/799c3e5b8e04d5fa1c9b61a4c1f5079603e747205c7311b92abd30283df5/virtualenv-21.14.7-py3-none-any.whl", hash = "sha256:3769219a308c5d2f093e7729621ad12a6346b5767a7ec4338414e2a0fb0c526b" }, ]