Running an nnU-Net checkpoint through vesuvius.predict normalizes the input with per-volume z-scoring even when the checkpoint's own plans.json declares something else. The plans are loaded and stay in hand. They are just never consulted when the normalization is chosen, and nothing warns, so the run looks normal and the outputs look plausible.
Where it happens, on main at a6c8ad5.
--normalization defaults to instance_zscore in the CLI parser, vesuvius/src/vesuvius/models/run/inference.py:1165, and the constructor default is the same at :252.
self.model_normalization_scheme and self.model_intensity_properties both start as None at :319-320.
- Two branches fill them in. The resnet branch sets the scheme at
:405, and the train.py checkpoint path sets both at :542-549.
- The nnU-Net branches do not.
--model_type nnunet at :419-426 and the auto fallback at :440-448 both call load_model_for_inference, and the model_info it returns carries plans_manager and configuration_manager but no normalization keys, vesuvius/src/vesuvius/utils/models/load_nnunet_model.py:297-308. The only place that loader builds those keys is the HuggingFace train.py branch at :386-387.
- The
hf:// path at inference.py:378-384 calls the same loader and lands in the same hole, unless the repo turns out to be a train.py checkpoint, which is the branch tested right after at :387. That is the form vesuvius/docs/inference.md leads with, so it is not an edge case.
- So
self.model_normalization_scheme or self.normalization_scheme at inference.py:696 resolves to the CLI default, and VCDataset is constructed with it at :727, inside the call that runs :721-749.
- The scheme actually in use is printed only under
verbose, vesuvius/src/vesuvius/data/vc_dataset.py:187, so a default run says nothing about normalization at all. Across 16 non-verbose runs of the checkpoint below, no log contains the string normaliz.
The information is already in hand at that point. configuration_manager.normalization_schemes and plans_manager.foreground_intensity_properties_per_channel are both populated by the loader, and inference.py reads neither.
There is also no way to ask for the right scheme from the outside. The --normalization help lists instance_zscore, global_zscore, instance_minmax and none. Volume does implement a ct scheme that is nnU-Net's CTNormalization verbatim, vesuvius/src/vesuvius/data/volume.py:970-980, but inference.py never passes intensity_props down to VCDataset, so --normalization ct raises at volume.py:234-237. --normalization global_zscore fails from the other side, since global_mean and global_std are read off model_intensity_properties, which is None on this path, inference.py:715-717.
That ct failure is also reported badly, which is worth fixing on its own. The ValueError from Volume is re-raised by VCDataset at vesuvius/src/vesuvius/data/vc_dataset.py:271, and Inferer.infer() catches it at inference.py:1106-1109, prints it, and then falls off the end of the method with an implicit None. main() unpacks that return at :1294 and dies on a TypeError, so the last line printed is cannot unpack non-iterable NoneType object rather than the missing intensity properties. The output directory was created back in the constructor at :352, so what is left on disk is an empty logits directory. The else branch at :1366-1368 that exists to report a None result cannot be reached, because the unpack raises first. The exit status is still 1, so a caller that checks it is not misled.
What it costs, measured with vesuvius.predict itself on the public surface_m7 checkpoint. Its plans.json declares CTNormalization for both 2d and 3d_fullres, with mean 87.5442, std 47.7438 and clipping to [0, 212]. I ran that checkpoint through the wrapper at fixed geometry two ways over 16 volumes of the public Kaggle 892 set, with everything but the normalization held constant. The first run was the wrapper as it comes. The second handed it a block already CT-normalized with the checkpoint's own plans values and asked for --normalization none, which is the only way to get CT normalization through this path today.
Median recall is 0.757 on the default and 0.934 on the CT-normalized input. Median precision is 0.442 against 0.739, and the predicted-positive fraction is 0.284 against 0.241. Recall and precision are higher on the CT side on all 16 volumes, and the predicted-positive fraction is lower on all 16. The median per-volume difference is 0.157 in recall, 0.228 in precision and 0.049 in predicted-positive fraction. Both arms also reproduce outside villa on a plain nnUNetPredictor loop, so this is not something my harness is doing. That loop matches the CT-normalized wrapper run to four decimals on all 16 volumes, and the default wrapper run to 0.0007 in median recall.
Those 16 are not a neutral draw and I would not quote 0.157 as a typical figure. They are 16 volumes out of the sample_00161 to sample_00178 block, all from the located population, and their centred 256-cube means have a median of 132.70 against a plans mean of 87.5442, about 0.95 plans standard deviations up. That is the part of the corpus where the two schemes have the most room to disagree.
How large it gets depends on how far a volume's own statistics sit from the training fingerprint, since that is what instance z-score substitutes for them. The 16 above sit about one sigma up. A volume whose own mean and spread happen to land near the plans values would come out close either way, so a corpus with more than one intensity population can read as though the model behaves differently on each part of it when some of the difference is the normalization. I have a larger comparison that shows that pattern and will add it here if it is useful.
A second defect sits in front of this one on a current stack. load_model calls trainer_class.build_network_architecture at vesuvius/src/vesuvius/utils/models/load_nnunet_model.py:241-248, passing the architecture class name, its kwargs and the required-import list positionally. nnunetv2 2.8.1 declares that staticmethod as (plans_manager, configuration_manager, num_input_channels, num_output_channels, enable_deep_supervision), so the call raises TypeError and :249-252 re-raises it as a RuntimeError. The fallback that would otherwise cover a missing trainer is initialize_network, and it is broken on its own. At :122-124 it runs exec(f"import {i}") over configuration_manager.network_arch_init_kwargs_req_import, whose entries are the names of architecture kwargs, conv_op, norm_op, dropout_op and nonlin for this checkpoint, rather than module paths. nnU-Net's own loader instead pydoc.locates the values those names hold. So an nnU-Net checkpoint does not load against nnunetv2 2.8.1 by either route, and I had to substitute nnU-Net's own construction to run the two arms above. On 0.2.4 the trainer failure is swallowed rather than re-raised, so the run reaches the initialize_network bug instead, which means both halves are live depending on the version. One side effect is that on a current nnunetv2 the normalization default is unreachable, since the run dies before any data is read, and that may be part of why it has gone unreported.
Three changes, and the first one is the normalization fix.
- Fill the scheme and the intensity properties in the nnU-Net loading path, and pass the properties down to VCDataset in the same change.
self.model_normalization_scheme and self.model_intensity_properties can come from configuration_manager.normalization_schemes and plans_manager.foreground_intensity_properties_per_channel, the way the resnet and train.py branches already do for their own checkpoints, but neither value transfers as it stands. normalization_schemes is a per-channel list in nnU-Net's vocabulary, ['CTNormalization'] for this checkpoint, so it has to be mapped to the string Volume expects, which is ct. foreground_intensity_properties_per_channel is keyed by channel index as a string, so the single input channel lives under "0". The VCDataset half is not optional either. Setting the scheme alone turns a silent wrong answer into a crash, because :696 would then resolve to ct while VCDataset is still built without intensity_props at :727, and Volume raises at volume.py:234-237. While in there, ct is worth listing in the --normalization help.
- Separately from that, warn when the checkpoint's plans declare a scheme other than the one about to be used, rather than taking the CLI default silently. That does not fix a run, but it stops the failure from being invisible, and it is cheap enough to land on its own. The
ct crash path deserves the same treatment, so give Inferer.infer() an explicit return in its except branch and let main() reach the None branch it already has.
- In the nnU-Net loader, call
build_network_architecture with the signature the installed nnunetv2 declares, since the call at :241-248 is the pre-2.8.1 order. That is the half a current stack hits, because the trainer resolves and :249-252 re-raises rather than falling back. The fallback is worth repairing in the same pass, since initialize_network at :122-124 imports the kwarg names instead of locating the values they hold.
I am happy to send the repro. It is one public checkpoint, one public volume and the two wrapper runs, printing recall and precision against the same labels both ways. On nnunetv2 2.8.1 the loader has to be worked around first, for the reason above. Say if a PR would be more useful than the report.
Running an nnU-Net checkpoint through
vesuvius.predictnormalizes the input with per-volume z-scoring even when the checkpoint's own plans.json declares something else. The plans are loaded and stay in hand. They are just never consulted when the normalization is chosen, and nothing warns, so the run looks normal and the outputs look plausible.Where it happens, on main at a6c8ad5.
--normalizationdefaults toinstance_zscorein the CLI parser,vesuvius/src/vesuvius/models/run/inference.py:1165, and the constructor default is the same at:252.self.model_normalization_schemeandself.model_intensity_propertiesboth start as None at:319-320.:405, and the train.py checkpoint path sets both at:542-549.--model_type nnunetat:419-426and the auto fallback at:440-448both callload_model_for_inference, and the model_info it returns carriesplans_managerandconfiguration_managerbut no normalization keys,vesuvius/src/vesuvius/utils/models/load_nnunet_model.py:297-308. The only place that loader builds those keys is the HuggingFace train.py branch at:386-387.hf://path atinference.py:378-384calls the same loader and lands in the same hole, unless the repo turns out to be a train.py checkpoint, which is the branch tested right after at:387. That is the formvesuvius/docs/inference.mdleads with, so it is not an edge case.self.model_normalization_scheme or self.normalization_schemeatinference.py:696resolves to the CLI default, and VCDataset is constructed with it at:727, inside the call that runs:721-749.verbose,vesuvius/src/vesuvius/data/vc_dataset.py:187, so a default run says nothing about normalization at all. Across 16 non-verbose runs of the checkpoint below, no log contains the stringnormaliz.The information is already in hand at that point.
configuration_manager.normalization_schemesandplans_manager.foreground_intensity_properties_per_channelare both populated by the loader, and inference.py reads neither.There is also no way to ask for the right scheme from the outside. The
--normalizationhelp lists instance_zscore, global_zscore, instance_minmax and none.Volumedoes implement actscheme that is nnU-Net's CTNormalization verbatim,vesuvius/src/vesuvius/data/volume.py:970-980, but inference.py never passesintensity_propsdown to VCDataset, so--normalization ctraises atvolume.py:234-237.--normalization global_zscorefails from the other side, sinceglobal_meanandglobal_stdare read offmodel_intensity_properties, which is None on this path,inference.py:715-717.That
ctfailure is also reported badly, which is worth fixing on its own. TheValueErrorfrom Volume is re-raised by VCDataset atvesuvius/src/vesuvius/data/vc_dataset.py:271, andInferer.infer()catches it atinference.py:1106-1109, prints it, and then falls off the end of the method with an implicit None.main()unpacks that return at:1294and dies on aTypeError, so the last line printed iscannot unpack non-iterable NoneType objectrather than the missing intensity properties. The output directory was created back in the constructor at:352, so what is left on disk is an empty logits directory. Theelsebranch at:1366-1368that exists to report a None result cannot be reached, because the unpack raises first. The exit status is still 1, so a caller that checks it is not misled.What it costs, measured with
vesuvius.predictitself on the public surface_m7 checkpoint. Its plans.json declares CTNormalization for both 2d and 3d_fullres, with mean 87.5442, std 47.7438 and clipping to [0, 212]. I ran that checkpoint through the wrapper at fixed geometry two ways over 16 volumes of the public Kaggle 892 set, with everything but the normalization held constant. The first run was the wrapper as it comes. The second handed it a block already CT-normalized with the checkpoint's own plans values and asked for--normalization none, which is the only way to get CT normalization through this path today.Median recall is 0.757 on the default and 0.934 on the CT-normalized input. Median precision is 0.442 against 0.739, and the predicted-positive fraction is 0.284 against 0.241. Recall and precision are higher on the CT side on all 16 volumes, and the predicted-positive fraction is lower on all 16. The median per-volume difference is 0.157 in recall, 0.228 in precision and 0.049 in predicted-positive fraction. Both arms also reproduce outside villa on a plain nnUNetPredictor loop, so this is not something my harness is doing. That loop matches the CT-normalized wrapper run to four decimals on all 16 volumes, and the default wrapper run to 0.0007 in median recall.
Those 16 are not a neutral draw and I would not quote 0.157 as a typical figure. They are 16 volumes out of the sample_00161 to sample_00178 block, all from the located population, and their centred 256-cube means have a median of 132.70 against a plans mean of 87.5442, about 0.95 plans standard deviations up. That is the part of the corpus where the two schemes have the most room to disagree.
How large it gets depends on how far a volume's own statistics sit from the training fingerprint, since that is what instance z-score substitutes for them. The 16 above sit about one sigma up. A volume whose own mean and spread happen to land near the plans values would come out close either way, so a corpus with more than one intensity population can read as though the model behaves differently on each part of it when some of the difference is the normalization. I have a larger comparison that shows that pattern and will add it here if it is useful.
A second defect sits in front of this one on a current stack.
load_modelcallstrainer_class.build_network_architectureatvesuvius/src/vesuvius/utils/models/load_nnunet_model.py:241-248, passing the architecture class name, its kwargs and the required-import list positionally. nnunetv2 2.8.1 declares that staticmethod as(plans_manager, configuration_manager, num_input_channels, num_output_channels, enable_deep_supervision), so the call raisesTypeErrorand:249-252re-raises it as aRuntimeError. The fallback that would otherwise cover a missing trainer isinitialize_network, and it is broken on its own. At:122-124it runsexec(f"import {i}")overconfiguration_manager.network_arch_init_kwargs_req_import, whose entries are the names of architecture kwargs,conv_op,norm_op,dropout_opandnonlinfor this checkpoint, rather than module paths. nnU-Net's own loader insteadpydoc.locates the values those names hold. So an nnU-Net checkpoint does not load against nnunetv2 2.8.1 by either route, and I had to substitute nnU-Net's own construction to run the two arms above. On 0.2.4 the trainer failure is swallowed rather than re-raised, so the run reaches theinitialize_networkbug instead, which means both halves are live depending on the version. One side effect is that on a current nnunetv2 the normalization default is unreachable, since the run dies before any data is read, and that may be part of why it has gone unreported.Three changes, and the first one is the normalization fix.
self.model_normalization_schemeandself.model_intensity_propertiescan come fromconfiguration_manager.normalization_schemesandplans_manager.foreground_intensity_properties_per_channel, the way the resnet and train.py branches already do for their own checkpoints, but neither value transfers as it stands.normalization_schemesis a per-channel list in nnU-Net's vocabulary,['CTNormalization']for this checkpoint, so it has to be mapped to the string Volume expects, which isct.foreground_intensity_properties_per_channelis keyed by channel index as a string, so the single input channel lives under"0". The VCDataset half is not optional either. Setting the scheme alone turns a silent wrong answer into a crash, because:696would then resolve toctwhile VCDataset is still built withoutintensity_propsat:727, and Volume raises atvolume.py:234-237. While in there,ctis worth listing in the--normalizationhelp.ctcrash path deserves the same treatment, so giveInferer.infer()an explicit return in its except branch and letmain()reach the None branch it already has.build_network_architecturewith the signature the installed nnunetv2 declares, since the call at:241-248is the pre-2.8.1 order. That is the half a current stack hits, because the trainer resolves and:249-252re-raises rather than falling back. The fallback is worth repairing in the same pass, sinceinitialize_networkat:122-124imports the kwarg names instead of locating the values they hold.I am happy to send the repro. It is one public checkpoint, one public volume and the two wrapper runs, printing recall and precision against the same labels both ways. On nnunetv2 2.8.1 the loader has to be worked around first, for the reason above. Say if a PR would be more useful than the report.