Skip to content

Fix mutable defaults in TSHM constructors#163

Open
harmening wants to merge 2 commits into
devfrom
fix-mutable-defaults-TSHM
Open

Fix mutable defaults in TSHM constructors#163
harmening wants to merge 2 commits into
devfrom
fix-mutable-defaults-TSHM

Conversation

@harmening
Copy link
Copy Markdown
Contributor

This might be a rare case, but I encountered it while constructing head models including lesions.

The bug is mutable default arguments on TwoSurfaceHeadModel.from_segmentation (and .from_surfaces). Their mask_files dict and brain_seg_types / scalp_seg_types list defaults are evaluated once at function-definition time, so every call that doesn't pass those kwargs explictely shares the same underlying objects.

I wrote code that extends/mutates those defaults in place (a small helper appending a "lesion" entry), but that permanently changes what every subsequent call sees. All next calls to from_segmentation then run with a polluted mask set, often failing (e.g., with FileNotFoundError deep inside the NIFTI reader for a file the user never asked for).

Here's a little code snippet replicating the problem:

"""Demo: how a build_models()-style caller poisons a shared mutable default.

Two subclasses of TSHM are compared:
* LegacyTSHM — re-introduces the pre-fix signature with mutable
  dict/list defaults, plus an enable_lesion flag that mutates them
  in place (a realistic library-side pattern).
* FixedTSHM — same flag, but defaults are None and materialized
  inside the body, matching the cedalion-fixed shape.
"""

from __future__ import annotations

import cedalion.data
import cedalion.dot.head_model as _hm_module
from cedalion.dot.head_model import TwoSurfaceHeadModel


# ---------------------------------------------------------------------------
# colin27 mask filenames — match cedalion.data.get_colin27_headmodel_files().
# ---------------------------------------------------------------------------
COLIN_MASKS = {
    "csf": "mask_csf.nii",
    "gm": "mask_gray.nii",
    "scalp": "mask_skin.nii",
    "skull": "mask_bone.nii",
    "wm": "mask_white.nii",
}


class _DemoDone(Exception):
    """Raised by the stubbed reader so we don't actually build surfaces."""


def _stub_read_segmentation_masks(segmentation_dir, mask_files):
    print("          read_segmentation_masks called with:")
    print(f"            mask_files       = {mask_files}")
    raise _DemoDone()


# Install the stub so both subclasses don't really load .nii (FileNotFoundError)
_hm_module.read_segmentation_masks = _stub_read_segmentation_masks


# ---------------------------------------------------------------------------
# BUGGY: pre-fix signature reintroduced on a TSHM subclass.
# ---------------------------------------------------------------------------
class LegacyTSHM(TwoSurfaceHeadModel):
    @classmethod
    def from_segmentation(
        cls,
        segmentation_dir,
        mask_files={                           # mutable default — the bug
            "csf": "mask_csf.nii",
            "gm": "mask_gray.nii",
            "scalp": "mask_skin.nii",
            "skull": "mask_bone.nii",
            "wm": "mask_white.nii",
        },
        landmarks_ras_file=None,
        brain_seg_types=["gm", "wm"],          # mutable default — the bug
        scalp_seg_types=["scalp"],             # mutable default — the bug
        smoothing=0.0,
        brain_face_count=180000,
        scalp_face_count=60000,
        fill_holes=True,
        enable_lesion=False,
    ):
        # Realistic library-side mutation: an option grows the mask dict.
        if enable_lesion:
            mask_files["lesion"] = "mask_lesion.nii"
            brain_seg_types.append("lesion")
        return super().from_segmentation(
            segmentation_dir,
            mask_files=mask_files,
            landmarks_ras_file=landmarks_ras_file,
            brain_seg_types=brain_seg_types,
            scalp_seg_types=scalp_seg_types,
            smoothing=smoothing,
            brain_face_count=brain_face_count,
            scalp_face_count=scalp_face_count,
            fill_holes=fill_holes,
        )


# ---------------------------------------------------------------------------
# FIXED: same flag, but defaults are None and materialized inside.
# ---------------------------------------------------------------------------
class FixedTSHM(TwoSurfaceHeadModel):
    @classmethod
    def from_segmentation(
        cls,
        segmentation_dir,
        mask_files=None,
        landmarks_ras_file=None,
        brain_seg_types=None,
        scalp_seg_types=None,
        smoothing=0.0,
        brain_face_count=180000,
        scalp_face_count=60000,
        fill_holes=True,
        enable_lesion=False,
    ):
        if mask_files is None:
            mask_files = dict(COLIN_MASKS)
        if brain_seg_types is None:
            brain_seg_types = ["gm", "wm"]
        if scalp_seg_types is None:
            scalp_seg_types = ["scalp"]

        if enable_lesion:
            mask_files["lesion"] = "mask_lesion.nii"
            brain_seg_types.append("lesion")
        return super().from_segmentation(
            segmentation_dir,
            mask_files=mask_files,
            landmarks_ras_file=landmarks_ras_file,
            brain_seg_types=brain_seg_types,
            scalp_seg_types=scalp_seg_types,
            smoothing=smoothing,
            brain_face_count=brain_face_count,
            scalp_face_count=scalp_face_count,
            fill_holes=fill_holes,
        )


def build_models(head_cls, subjects, base_dir):
    """Loop over subjects, enabling lesion mode on the first one only."""
    for subj, want_lesion in subjects:
        print(f"  → {head_cls.__name__}.from_segmentation"
              f"({subj!r}, enable_lesion={want_lesion})")
        try:
            head_cls.from_segmentation(str(base_dir), enable_lesion=want_lesion)
        except _DemoDone:
            pass


def main() -> None:
    files = cedalion.data.get_colin27_headmodel_files()
    print(f"colin27 mask dir: {files.basedir}\n")

    subjects = [
        ("subject_01_with_lesion", True),
        ("subject_02",             False),
        ("subject_03",             False),
    ]

    print("=" * 70)
    print("BUGGY (LegacyTSHM): subject_01's lesion bleeds into 02 and 03 :D")
    print("=" * 70)
    build_models(LegacyTSHM, subjects, files.basedir)

    print()
    print("=" * 70)
    print("FIXED (FixedTSHM, mirrors cedalion-fixed TSHM): each call clean")
    print("=" * 70)
    build_models(FixedTSHM, subjects, files.basedir)


if __name__ == "__main__":
    main()

@harmening harmening changed the base branch from main to dev May 11, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant