WiseSegmentator 工程优化:消除僵尸代码、静默错误与副作用
引言
上一篇我写了 WiseSegmentator 的架构设计——两级目标体系、ROI 裁剪推理、Predictor 生命周期管理。那些是”看得见”的设计。
这篇文章写的是”看不见”的东西:一个研究项目型的代码仓库,在架构定型之后、真正达到可维护状态之前,还需要经过哪些打磨。
我把问题按严重程度分为 P0→P3 四个等级,逐轮修复。回头看,每轮修的都是不同层级的问题——从”代码能不能跑对”到”别人能不能参与贡献”。
问题分级框架
| 等级 | 定义 | 典型表现 |
|---|---|---|
| P0 | 正确性风险 — 代码在特定条件下会静默失败 | 僵尸抽象、异常被吞、环境变量被覆盖 |
| P1 | 开发者体验 — 功能正确但阻碍调试和维护 | 裸 print、tqdm 与日志冲突、无持久化日志 |
| P2 | 功能完整性 — 缺少用户可感知的关键功能 | 无缓存/断点续跑、无输入校验、优先级黑盒 |
| P3 | 质量保障 — 缺少自动化验证手段 | 无 CI、测试覆盖薄弱、mock 不足 |
这个分级来自于一个朴素的原则:先保证代码不撒谎,再让它易维护,然后补全用户需要的功能,最后用自动化守住底线。
P0:三类会”说谎”的代码
ModelManager:一个从未被实现的抽象
打开 wisesegmentator/models/manager.py,你能看到一个叫 ModelManager 的类,提供了 load_model()、download_model()、clear_cache() 等看起来”很正式”的接口:
class ModelManager:
def load_model(self, model_name: str):
...
return self._load_model_impl(model_path)
def _load_model_impl(self, model_path):
raise NotImplementedError # 从第一天就不存在
它承诺了模型加载、下载、缓存管理——但实际上 _load_model_impl() 永远抛出 NotImplementedError。真正的模型加载在 executor/model_executor.py 里通过 WiseNNUNetPredictor 完成,走的完全是另一条路。
这是一种典型的”研究代码遗留”:项目初期按想象中的架构搭建骨架,但实际执行路径绕过了它。类留在那里,给后来的维护者一个假象——”这里有一个模型管理器,你可以通过它扩展”。
处理方式:直接删除。没有重构,没有”以后可能会用”,没有”留个 TODO”。
在清理研究代码时有一条铁律:僵尸代码不是技术债务——它是谎言。 技术债务是有价值但需要改进的代码;谎言是从未履行过承诺的代码。对前者要重构,对后者要删除。
连带被清理的还有 predict_from_nifti() 和 predict_from_array()——这两个方法在 WiseNNUNetPredictor 中被定义但从未被调用,唯一被使用的是 predict_from_file()。以及 spatial_restore.py 中 ~265 行的废弃函数 restore_to_original_space 和相关辅助函数,在重构后只保留了 get_image_metadata。
Monkey-patch 失败静默吞掉
TotalSegmentator 的肺结节模型使用了一个自定义 Trainer nnUNetTrainer_MOSAIC_1k_QuarterLR_NoMirroring,它不在标准 nnUNetv2 的类注册表中。为了让 nnUNet 能识别它,需要在模块导入时 monkey-patch recursive_find_python_class 函数:
# 修复前
def _inject_custom_trainers():
try:
from nnunetv2.utilities.find_class import recursive_find_python_class
# ... 注入自定义 trainer
except Exception:
pass # 失败了也无所谓?
except Exception: pass ——没有日志,没有 warning,什么都没有。如果 nnUNetv2 的 API 变了导致 monkey-patch 失败,用户只会在运行到肺结节模型时遇到一个神秘的 ClassNotFoundError,而完全不知道根因。
修复方式:
# 修复后
def _inject_custom_trainers():
try:
from nnunetv2.utilities.find_class import recursive_find_python_class
# ... 注入自定义 trainer
except Exception:
import warnings
warnings.warn(
"Failed to inject TotalSegmentator custom trainers. "
"ts_lung_nodules model may not work.",
RuntimeWarning
)
bare except: pass 是 Python 代码里最常见的反模式之一。如果一段代码的失败值得写 try-except,那它一定值得记录。
导入时副作用覆盖环境变量
最隐蔽的 bug 往往和导入顺序有关。
# nnunet_predictor.py 模块级代码
def _setup_nnunet_env():
os.environ["nnUNet_raw"] = "/tmp/nnunet_raw"
os.environ["nnUNet_preprocessed"] = "/tmp/nnunet_preprocessed"
os.environ["nnUNet_results"] = "/tmp/nnunet_results"
_setup_nnunet_env() # 模块导入即执行
这段代码的动机是好的——抑制 nnunetv2 启动时检查环境变量的警告。但它有两个问题:
- 覆盖用户配置:如果用户已经设置了
nnUNet_results指向自己的模型目录,导入 WiseSegmentator 后会悄无声息地被覆盖。 - 时机错误:调用发生在
WiseNNUNetPredictor.__init__里,但 nnunetv2 在导入时就检查环境变量——太晚了,警告已经打出来了。
修复:
def _setup_nnunet_env():
for var in ("nnUNet_raw", "nnUNet_preprocessed", "nnUNet_results"):
if var not in os.environ:
os.environ[var] = "/tmp/nnunet_default"
两处改动:检查 not in 避免覆盖已有值;调用从类的 __init__ 提升到模块级别(在 from nnunetv2 import ... 之前)。这次改动同时消除了导入阶段的警告和运行时的副作用。
P1:开发体验 —— 你不可能调试看不见的东西
日志系统的六个级别
最初的代码里混用 print() 和 logging,而且 tqdm 进度条和日志消息互相争抢 stdout。对于命令行工具来说,日志输出质量直接决定用户的第一印象。
重写的日志系统在 utils/logging.py 中集中管理,核心设计:
| 级别 | 标签 | 颜色 | 用途 |
|---|---|---|---|
| DEBUG | [DEBUG ] |
灰色 | shape/spacing/GPU 显存诊断 |
| INFO | [ INFO ] |
白色 | 模型开始、流程步骤 |
| SUCCESS (25) | [ OK ] |
绿色 | 模型完成、后处理完成、总耗时 |
| WARNING | [ WARN ] |
黄色 | 非致命错误 |
| ERROR | [ERROR ] |
红色 | 模型失败 |
SUCCESS 是用 logging.addLevelName 注册的自定义级别(25,介于 INFO 和 WARNING 之间)。6-char 固定宽度的级别标签保证输出对齐,ANSI 颜色码只在终端输出时启用(通过 sys.stderr.isatty() 判断),写日志文件时不带颜色码。
tqdm 兼容使用标准的 tqdm.write() 模式:自定义 handler 在写消息前通过 tqdm.external_write_mode() 上下文管理器暂停进度条,避免输出撕裂。
为什么这点很重要
修复 P0 问题时我第一次意识到:如果 monkey-patch 失败时有 warning,这个问题可能一周前就被发现了。 P1 的日志优化不是为了”好看”——它是所有后续调试工作的基础设施。
P2:用户能感知到的功能缺口
指纹缓存:为什么重复运行要重新推理?
这是实际使用中暴露的痛点。一个场景:用户跑了 wiseseg -p all,发现肝脏分段不太对,想调一下后处理参数。但重新运行时,所有 9 个模型又要全部推理一遍——4 分多钟的等待,只为了改一个后处理阈值。
指纹缓存的思路很简单:
输入 Nifti → SHA256 → 检查 models/model-{name}/.fingerprint.json
├── 命中: 加载 _segmentation_full.nii.gz → 跳过推理
└── 未命中: 执行推理 → 保存指纹 + 结果
实现细节在 executor/model_executor.py 中,三个方法:
_compute_input_hash():对输入文件分块计算 SHA256(8KB chunks,避免大文件内存问题)_check_fingerprint():读取.fingerprint.json比对input_sha256和model_name_save_fingerprint():保存指纹 JSON + 完整分割结果
缓存按模型粒度而非流水线粒度——这意味着即使你修改了 task_registry.json 只影响了一个模型,其他 8 个仍然可以命中缓存。
一个重要的设计决策:缓存的 key 是 input_sha256 而不是输入文件路径。这意味着同一个文件移到不同目录仍然能命中。反过来,内容不同的文件即使路径相同也会重新推理。
输入预校验:早报错 vs 跑到一半才挂
原始行为:
用户输入 2D 切片 → 模型执行到一半 → nnUNet 报错 "expected 3D input"
→ 栈回溯中找不到清晰的错误原因
修复后:
用户输入 2D 切片 → _validate_input() 立即返回: "Input must be 3D, got shape (512, 512)"
_validate_input() 在流水线入口执行四步检查:文件存在性 → Nifti 有效性 → 最少 3 维 → 每轴至少 2 体素。约 15 行代码,但节省了用户等待模型加载 50s 后才发现输入问题的时间。
显式 priority 字段
旧的模型优先级依赖名称前缀匹配:custom_* = 1, ts_* = 2, cads_* = 3。添加新模型时如果命名不规范,比如从第三方引入的叫 external_xxx_* 的模型,就会回退到 999(最低优先级)。
在 task_registry.json 中为每个条目增加 "priority" 字段后,_get_model_priority() 优先读取 JSON 配置,没配置才回退前缀规则。这比隐式约定更可靠。
P3:CI 烟雾测试 —— 自动化守住底线
在 P0-P2 的修改过程中,我手动跑了无数次 pytest。每次提交都担心”前面的改动是不是偷偷弄坏了后面的东西”。
烟雾测试的定位不是在 GPU 上跑真实推理——那属于集成测试,需要硬件支持。烟雾测试的目标是:用一个 mock 的 nnUNetPredictor,验证整个流水线的调度逻辑在”推理瞬间完成”的假设下是否正确。
@pytest.fixture
def mock_predictor():
"""mock nnUNetPredictor,返回合成数据而非真实推理"""
with patch("wisesegmentator.models.nnunet_predictor.nnUNetPredictor") as mock:
mock.return_value.predict_from_files.return_value = [
create_dummy_nifti((2, 2, 2)) # 合成 3D 分割结果
]
yield mock
10 个测试覆盖了三个维度:
- 完整流水线(3 个):单目标端到端、指纹缓存二次命中跳过、
-s二级过滤 - 指纹缓存单元(6 个):SHA256 计算、指纹读写、缓存命中/未命中、损坏文件容错
- 缓存跳过集成(1 个):
_execute_single_model命中指纹时跳过推理
有这 10 个测试兜底后,每次改调度逻辑——比如修改分组策略或依赖图——不需要手动跑一遍全量推理来验证。121 个单元测试在 CI 上 69 秒跑完。
六条经验
回头看这四轮优化,有几条规律值得记录。
僵尸代码不是”以后有用”,是”正在说谎”
删除未使用的代码比保留它风险更低。留下的空壳会给后来的维护者一个错误的承诺——”这个接口可以用”。尤其是定义了完整接口但实现只有 raise NotImplementedError 的类。
except Exception: pass 是定时炸弹
任何 bare except 都要问自己:如果这段代码失败了,用户应该知道吗?答案几乎总是”应该”。即使一个错误在当前版本下”永远不会发生”,依赖的第三方库会升级、API 会变化。
模块导入时的副作用是隐式契约
导入一个库不应该改变全局状态。_setup_nnunet_env() 修了两个问题后才明白:在 import 时产生副作用是一层隐式契约,所有 import 这个模块的代码都隐式依赖这个副作用。理想情况下应该用延迟初始化或显式 init() 函数。
日志系统是调试基础设施,值得早期投入
P1 的日志重写改动不大,但它是 P0 问题能被快速定位的前提。如果在修复 ModelManager 错误时还是满屏 print() 混 tqdm,排查时间会翻倍。
测试不是越”真实”越好
烟雾测试的价值在于它 mock 了最慢的部分而验证了最关键的部分。nnUNet 推理在 GPU 上跑一次 4 分钟,mock 掉后 69 秒。但它完整覆盖了从 TargetManager 解析到 ModelExecutor 分组到指纹缓存比对的全链路调度逻辑。
清理顺序很重要
P0(正确性)→ P1(可观测性)→ P2(功能)→ P3(自动化)的顺序不是随机的。没有 P1 的日志,P0 的问题很难定位。没有 P3 的 CI,P2 的改动不敢放心交付。每一级为下一级提供工具或信心。
结语
研究和工程之间的鸿沟,不在于代码多不多、架构好不好,而在于——当一段代码在你不看着它的时候,它能不能不出错地跑完。
WiseSegmentator 还有大量值得做的事:ONNX 转换提速、Group 2 按 ROI 体积动态并行、自动模型权重下载。但经过这四轮清理后,它从一个”作者的机器上能跑”的项目变成了一个”可以交给别人用”的工具。
代码仓库:https://github.com/YangCazz/CazzSegmentator
参考文献
- WiseSegmentator 设计文档. 统一多模型调度的 CT 多器官分割框架架构设计.
https://github.com/YangCazz/CazzSegmentator - nnUNetv2. Isensee F, et al. Self-configuring Method for Semantic Segmentation.
https://github.com/MIC-DKFZ/nnUNet - TotalSegmentator. Wasserthal J, et al. Robust Semantic Segmentation of 104 Anatomical Structures in CT Images.
https://github.com/wasserth/TotalSegmentator