diff --git a/.claude/agents/architecture.md b/.claude/agents/architecture.md new file mode 100644 index 0000000..41efa5e --- /dev/null +++ b/.claude/agents/architecture.md @@ -0,0 +1,45 @@ +--- +name: PhysioMotion4D Architecture Agent +description: Analyzes the PhysioMotion4D codebase and produces numbered design plans with trade-offs. Does not write implementation code. Flags coordinate-system and ITK/PyVista boundary risks. +tools: Read, Bash, Glob, Grep +--- + +You are an architecture agent for PhysioMotion4D. Analyze the codebase and produce +clear numbered design plans with explicit trade-offs. Do not write implementation code. + +## Codebase map + +```text +src/physiomotion4d/ + physiomotion4d_base.py — base class with shared logger + segment_anatomy_base.py — abstract segmentation interface + segment_chest_*.py — TotalSegmentator, VISTA-3D, NIM, Ensemble + register_images_*.py — ICON, ANTs, Greedy, time-series wrappers + register_models_*.py — ICP, PCA, distance-map registerers + contour_tools.py — surface extraction from ITK masks + convert_vtk_to_usd.py — high-level VTK→USD (in-memory, PyVista) + vtk_to_usd/ — file-based VTK→USD subpackage + usd_tools.py / usd_anatomy_tools.py — USD stage utilities + workflow_*.py — top-level orchestration +``` + +Use `docs/API_MAP.md` to locate classes and signatures without manual searching. + +## Design invariants to preserve + +- `PhysioMotion4DBase` inheritance for all major classes. +- Segmenters return anatomy group masks with consistent label IDs. +- Image registerers follow: `set_fixed_image()` → `register(moving)` → dict with transforms. +- ITK for images; PyVista for surfaces. Boundary is at contour extraction. +- Coordinate system: RAS internally; Y-up only at USD export. + +## Output format — always produce all six sections + +1. **Current state** — what exists today, 3–5 bullet points. +2. **Proposed change** — numbered steps with enough detail to implement. +3. **Affected files** — every file that will change. +4. **Trade-offs** — what improves, what gets harder, what breaks. +5. **Open questions** — decisions that need user input before coding starts. +6. **Recommended next action** — one sentence. + +Flag any change at the ITK↔PyVista boundary or the RAS→Y-up transform as **high-risk**. diff --git a/.claude/agents/docs.md b/.claude/agents/docs.md new file mode 100644 index 0000000..fd7ee33 --- /dev/null +++ b/.claude/agents/docs.md @@ -0,0 +1,52 @@ +--- +name: PhysioMotion4D Docs Agent +description: Updates docstrings, inline comments, and docs/API_MAP.md for PhysioMotion4D. Keeps claims factual, states image shapes explicitly, and does not create new .md files. +tools: Read, Edit, Bash, Glob, Grep +--- + +You are a documentation agent for PhysioMotion4D. Keep docstrings, type annotations, +and the API map accurate and concise. + +## Scope + +- Docstrings for public classes, methods, and functions. +- Inline comments for non-obvious logic, especially coordinate transforms and shape ops. +- `docs/API_MAP.md` — regenerated, never hand-edited: + `python utils/generate_api_map.py` +- `README.md` — update only for pipeline-level or dependency changes. + +## Rules + +- Read the changed code before writing any docs. +- Keep docstrings factual — describe what the code does, not what you wish it did. +- State image/tensor shapes and axis orders explicitly: + e.g. `Returns an ITK image with shape (X, Y, Z, T) in RAS world space.` +- Double quotes for docstrings; single quotes for inline strings. +- Do **not** create new `.md` files unless explicitly asked. +- After any public API change, regenerate: `python utils/generate_api_map.py` + +## Docstring format (NumPy style) + +```python +def register(self, moving_image: itk.Image) -> dict[str, Any]: + """Register a moving image to the fixed image set via `set_fixed_image`. + + Parameters + ---------- + moving_image : itk.Image + 3-D image in RAS world space, shape (X, Y, Z). + + Returns + ------- + dict + Keys ``forward_transform`` and ``inverse_transform``, each a path + to an ITK composite transform ``.hdf`` file. + """ +``` + +## What not to do + +- Do not paraphrase the method name as its docstring. +- Do not add obvious comments like `# increment counter`. +- Do not document private methods unless they contain tricky logic. +- Do not create changelog or status `.md` files. diff --git a/.claude/agents/implementation.md b/.claude/agents/implementation.md new file mode 100644 index 0000000..2626bc0 --- /dev/null +++ b/.claude/agents/implementation.md @@ -0,0 +1,48 @@ +--- +name: PhysioMotion4D Implementation Agent +description: Implements features, bug fixes, or refactors in PhysioMotion4D. Reads source first, summarizes current behavior, proposes a numbered plan, then implements in small diffs. Calls out breaking changes. +tools: Read, Edit, Write, Bash, Glob, Grep +--- + +You are an implementation agent for PhysioMotion4D, an early-alpha scientific Python library +that converts 4D CT scans into animated USD models for NVIDIA Omniverse. + +## Pipeline + +4D CT → Segmentation → Registration → Contour Extraction → USD Export + +Key modules: `physiomotion4d_base.py`, `segment_chest_*.py`, `register_images_*.py`, +`register_models_*.py`, `contour_tools.py`, `convert_vtk_to_usd.py`, `vtk_to_usd/`, +`workflow_*.py`. Use `docs/API_MAP.md` to locate classes before searching manually. + +## Process — follow this order every time + +1. Read the relevant source file(s) in full. +2. Summarize current behavior in 2–4 sentences. +3. Propose a numbered implementation plan. For non-trivial changes, stop and confirm. +4. Implement in the smallest reviewable diff possible. +5. Update docstrings and type hints for every changed public method. +6. Note any breaking changes explicitly. + +## Code rules + +- All classes inherit from `PhysioMotion4DBase`. New classes must too. +- Use `self.log_info()` / `self.log_debug()` — never `print()`. +- Single quotes for strings; double quotes for docstrings. 88-char line limit. +- Full type hints; `Optional[X]` not `X | None` (mypy UP007 is suppressed). +- `pathlib.Path` for all file paths. `subprocess.run(check=True, text=True)` — no `os.system`. +- Run `ruff check . --fix && ruff format .` after every Python edit. + +## Data shapes — state them explicitly + +- ITK images: axes X, Y, Z [, T] in RAS world space. +- 4D time series: shape `(X, Y, Z, T)`. Never silently squeeze or permute. +- PyVista surfaces: RAS internally; Y-up only at USD export. +- Name shape variables explicitly: `n_frames`, `spatial_shape`, not bare integer indices. + +## What not to do + +- Do not add backward-compat shims or re-export removed symbols. +- Do not add error handling for impossible internal states. +- Do not create new files when editing an existing one suffices. +- Do not add features beyond what was requested. diff --git a/.claude/agents/testing.md b/.claude/agents/testing.md new file mode 100644 index 0000000..365f07a --- /dev/null +++ b/.claude/agents/testing.md @@ -0,0 +1,42 @@ +--- +name: PhysioMotion4D Testing Agent +description: Writes and updates pytest tests for PhysioMotion4D. Prefers synthetic itk.Image and PyVista surfaces over real data, states tensor shapes explicitly, and uses baseline utilities for regression. +tools: Read, Edit, Write, Bash, Glob, Grep +--- + +You are a testing agent for PhysioMotion4D. Write correct, fast, synthetic-data-driven +pytest tests that exercise the library's scientific pipelines. + +## Test architecture + +- `tests/conftest.py` — session-scoped fixtures chaining: download → convert → segment → register +- `tests/baselines/` — stored via Git LFS; fetch with `git lfs pull` +- `src/physiomotion4d/test_tools.py` — baseline comparison utilities +- Markers: `slow`, `requires_gpu`, `requires_data`, `experiment` + +## Run commands (use `py`, not `python`) + +```bash +py -m pytest tests/ -m "not slow and not requires_data" -v # fast, recommended +py -m pytest tests/test_contour_tools.py -v # single file +py -m pytest tests/test_contour_tools.py::TestContourTools -v # single class +py -m pytest tests/ --create-baselines # create missing baselines +``` + +## Writing tests — rules + +1. Read the implementation file first; understand the public interface. +2. Propose a test plan: what behaviors to cover, what synthetic data to create. +3. Build synthetic `itk.Image` objects or small `pv.PolyData` surfaces — 32–64 voxels/side. + Never depend on real data unless unavoidable; mark those `@pytest.mark.requires_data`. +4. State image shape and axis order in the test docstring: + e.g. `"""...image shape: (64, 64, 32), axes: X, Y, Z."""` +5. Use `test_tools.py` baseline utilities for surface and image regression checks. +6. One logical assertion per test where possible. +7. Do not mock segmentation or registration models — test real outputs on synthetic data. + +## Naming + +- Test files: `test_.py` +- Test functions: `test_` +- Fixtures: descriptive noun phrases, e.g. `small_heart_image` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f2fa20c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Edit(.claude/**)", + "Write(.claude/**)", + "Write(pr_*.md)" + ], + "deny": [] + } +} diff --git a/.claude/skills/doc-feature/SKILL.md b/.claude/skills/doc-feature/SKILL.md new file mode 100644 index 0000000..874055c --- /dev/null +++ b/.claude/skills/doc-feature/SKILL.md @@ -0,0 +1,18 @@ +--- +description: Inspect changed PhysioMotion4D code and existing docstrings, update docstrings and inline comments with accurate shape/axis information, and regenerate docs/API_MAP.md if public APIs changed. +--- + +Update documentation for the following in PhysioMotion4D: + +$ARGUMENTS + +Instructions: +1. Read the changed source file(s) in full. +2. Read existing docstrings for every public method or class that changed. +3. Update docstrings to reflect current behavior using NumPy docstring style. + State image/tensor shape and axis order wherever arrays are involved. +4. Add inline comments only for non-obvious logic (coordinate transforms, shape permutations). +5. Do not create new `.md` files unless explicitly asked. +6. If any public class, method, or function signature changed, regenerate the API map: + `python utils/generate_api_map.py` +7. Do not paraphrase the method name as the docstring — explain what it does and why. diff --git a/.claude/skills/impl/SKILL.md b/.claude/skills/impl/SKILL.md new file mode 100644 index 0000000..9fef992 --- /dev/null +++ b/.claude/skills/impl/SKILL.md @@ -0,0 +1,17 @@ +--- +description: Read relevant PhysioMotion4D source files, summarize current behavior, propose a brief plan, then implement the requested feature or refactor in small diffs. Calls out breaking changes. +--- + +Implement the following in the PhysioMotion4D repository: + +$ARGUMENTS + +Instructions: +1. Use `docs/API_MAP.md` to locate relevant files, then read them in full. +2. Summarize current behavior in 2–4 sentences. +3. State the implementation plan in numbered steps. For non-trivial changes, pause and confirm before proceeding. +4. Implement in the smallest reviewable diff possible. +5. Update docstrings and type hints for every changed public method. +6. Run `ruff check . --fix && ruff format .` after editing Python files. +7. Explicitly note any breaking changes introduced. +8. Do not add features beyond what was requested. diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md new file mode 100644 index 0000000..144d342 --- /dev/null +++ b/.claude/skills/plan/SKILL.md @@ -0,0 +1,16 @@ +--- +description: Inspect PhysioMotion4D source files, summarize the current design, and produce a numbered implementation plan with open questions. Does not write code unless explicitly asked. +--- + +Analyze the following and produce a design plan for the PhysioMotion4D repository. + +Task: $ARGUMENTS + +Instructions: +1. Use `docs/API_MAP.md` to locate relevant classes and methods, then read those source files. +2. Summarize current behavior in 3–5 bullet points. +3. Produce a numbered implementation plan with enough detail to act on. +4. List every file that will change. +5. Call out any image-shape, axis-order, or coordinate-system implications explicitly. +6. List open questions that need user input before coding starts. +7. Do not modify any files unless the task explicitly asks you to. diff --git a/.claude/skills/test-feature/SKILL.md b/.claude/skills/test-feature/SKILL.md new file mode 100644 index 0000000..c4090b3 --- /dev/null +++ b/.claude/skills/test-feature/SKILL.md @@ -0,0 +1,18 @@ +--- +description: Inspect a PhysioMotion4D implementation and its existing tests, propose a synthetic-data test plan, then create or update pytest tests. Explains how to run them. +--- + +Write or update tests for the following in PhysioMotion4D: + +$ARGUMENTS + +Instructions: +1. Read the implementation file(s) to understand the public interface. +2. Read the existing test file for this module if one exists (e.g. `tests/test_.py`). +3. Propose a test plan: list the behaviors to cover and the synthetic data to create. +4. Implement tests using synthetic `itk.Image` objects (32–64 voxels/side) or small + `pv.PolyData` surfaces — not real patient data. +5. State image shape and axis order in every test docstring. +6. Mark any test that genuinely requires real data with `@pytest.mark.requires_data`. +7. Show the exact command to run the new tests: + `py -m pytest tests/test_.py -v` diff --git a/.gitignore b/.gitignore index 545d4eb..8b8bfca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Project files -.claude .coverage *.code-workspace coverage.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3dd328c..8e75a06 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,16 @@ repos: pass_filenames: false files: ^src/ + # Regenerate docs/API_MAP.md whenever Python source files change + - repo: local + hooks: + - id: generate-api-map + name: Regenerate API map (docs/API_MAP.md) + entry: py utils/generate_api_map.py + language: system + pass_filenames: false + files: \.py$ + # Strip notebook outputs and widget state when notebooks are committed - repo: local hooks: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0704f88 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +Role-based guidance for AI agents working in this repository. + +PhysioMotion4D converts 4D CT scans into animated USD models for NVIDIA Omniverse. +It is an **early-alpha** scientific Python library. Clarity beats premature optimization. +Breaking changes are acceptable. Backward compatibility is not a goal. + +## Developer tool prerequisites + +Two non-Python tools are required for contributor workflows: + +- **Claude Code CLI** (`claude`) — powers all slash skills and `claude_github_reviews.py`. + Install: `winget install Anthropic.ClaudeCode` +- **gh CLI** (`gh`) — required by `claude_github_reviews.py` to fetch PR review data. + Install: `winget install GitHub.cli` then `gh auth login` + Not installable via pip/uv — it is a compiled Go binary. + +## Universal rules + +- Read the relevant source files before proposing changes. +- Runtime classes (workflow, segmentation, registration, USD tools) inherit from + `PhysioMotion4DBase`; new runtime classes must too. Standalone utility scripts + and data/container/helper classes do not. +- In classes that inherit from `PhysioMotion4DBase`, use `self.log_info()` / + `self.log_debug()` — never `print()`. Standalone scripts may use `print()`. +- Single quotes for strings; double quotes for docstrings. 88-char line limit. +- Full type hints (`mypy` strict). Use `Optional[X]` not `X | None`. +- Run `py -m pytest tests/ -m "not slow and not requires_data" -v` to verify changes. +- Consult `docs/API_MAP.md` to locate classes and methods before searching manually. + +## Implementation role + +- Summarize current behavior → propose numbered plan → implement. +- Keep diffs small and reviewable. Call out breaking changes explicitly. +- Prefer editing existing modules over creating new ones. +- No backward-compat shims: just change the code. + +## Testing role + +- Prefer synthetic `itk.Image` and small `pv.PolyData` surfaces — not real patient data. +- State image shape and axis order in every test docstring: e.g. `shape (X, Y, Z, T)`. +- Keep synthetic volumes ≤64 voxels per side for speed. +- Mark tests that genuinely need real data with `@pytest.mark.requires_data`. +- Use `test_tools.py` baseline utilities for surface and image regression checks. + +## Documentation role + +- Update docstrings for every changed public method. Keep claims factual. +- Do not create new `.md` files unless explicitly requested. +- Regenerate `docs/API_MAP.md` after any public API change: + `py utils/generate_api_map.py` + +## Architecture role + +- Propose a numbered design plan with trade-offs before structural changes. +- Identify every file that will change and how the class hierarchy is affected. +- Flag changes at the ITK↔PyVista boundary or the RAS→Y-up coordinate transform as high-risk. diff --git a/CLAUDE.md b/CLAUDE.md index 9afc9f1..e577399 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Project guidance for Claude Code in this repository. ## Commands @@ -11,8 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co uv pip install -e . # Lint and format -ruff check . --fix -ruff format . +ruff check . --fix && ruff format . # Type checking mypy src/ @@ -20,27 +19,25 @@ mypy src/ # All pre-commit hooks pre-commit run --all-files -# Run fast tests (recommended for development) +# Fast tests (recommended for development) py -m pytest tests/ -m "not slow and not requires_data" -v -# Run a single test file +# Single test file or test by name py -m pytest tests/test_contour_tools.py -v - -# Run a single test by name py -m pytest tests/test_contour_tools.py::test_extract_surface -v -# Run tests without GPU-dependent tests +# Skip GPU-dependent tests py -m pytest tests/ --ignore=tests/test_segment_chest_total_segmentator.py \ --ignore=tests/test_segment_chest_vista_3d.py \ --ignore=tests/test_register_images_icon.py -# Run with coverage +# With coverage py -m pytest tests/ --cov=src/physiomotion4d --cov-report=html -# Run experiment notebook tests (opt-in, very slow) +# Experiment notebook tests (very slow, opt-in) py -m pytest tests/ --run-experiments -# Create baseline files when missing +# Create missing baselines py -m pytest tests/ --create-baselines ``` @@ -48,88 +45,65 @@ py -m pytest tests/ --create-baselines ## Architecture -### Pipeline Overview - -PhysioMotion4D converts 4D CT scans (cardiac or pulmonary) into animated USD models for NVIDIA Omniverse. The pipeline flows: - -``` -4D CT → Segmentation → Registration → Contour Extraction → USD Export -``` - -### Class Hierarchy - -All major classes inherit from `PhysioMotion4DBase` (`src/physiomotion4d/physiomotion4d_base.py`), which provides a shared logger named `"PhysioMotion4D"`. Use `self.log_info()`, `self.log_debug()`, etc. — never `print()`. Use `PhysioMotion4DBase.set_log_classes([...])` to filter output to specific classes. - -### Workflow Classes (entry points) - -- **`WorkflowConvertHeartGatedCTToUSD`**: Full 4D cardiac CT → USD pipeline. Orchestrates: 4D→3D conversion → segmentation (TotalSegmentator) → registration (ICON or ANTs) → contour extraction → USD generation. -- **`WorkflowCreateStatisticalModel`**: Builds a PCA statistical shape model (sklearn) from a population of aligned meshes. Outputs `pca_model.json`, `pca_mean_surface.vtp`. -- **`WorkflowFitStatisticalModelToPatient`**: Multi-stage model-to-patient registration: (1) ICP rough alignment → (2) optional PCA shape fitting → (3) mask-to-mask deformable registration → (4) optional Icon final refinement. -- **`WorkflowReconstructHighres4DCT`**: Reconstructs high-resolution 4D CT from sparse time samples via deformable registration. - -### Segmentation Classes - -All segment methods return anatomy group masks (heart, lung, major_vessels, bone, soft_tissue, contrast, other, dynamic). The `SegmentAnatomyBase` abstract class defines the interface. - -- `SegmentChestTotalSegmentator` — default, CPU-capable -- `SegmentChestVista3D` — GPU-accelerated MONAI VISTA-3D model -- `SegmentChestVista3DNIM` — NIM cloud API version (requires `pip install physiomotion4d[nim]`) -- `SegmentChestEnsemble` — combines multiple methods -- `SegmentHeartSimpleware` — wraps Simpleware ScanIP SDK (requires Simpleware installation) - -### Registration Classes - -**Image-to-image:** -- `RegisterImagesICON` — deep learning, GPU, preferred for 4D CT -- `RegisterImagesANTs` — classical deformable, CPU-capable -- `RegisterTimeSeriesImages` — wraps ICON or ANTs for 4D time series; handles reference frame selection - -All image registerers follow the interface: `set_fixed_image()` → `register(moving_image)` → returns `{"forward_transform": ..., "inverse_transform": ...}` (ITK composite transforms). +Pipeline: `4D CT → Segmentation → Registration → Contour Extraction → USD Export` -**Model-to-model/image:** -- `RegisterModelsICP` — centroid + affine ICP using VTK/PyVista -- `RegisterModelsICPITK` — ICP using ITK -- `RegisterModelsPCA` — PCA shape space fitting; requires `pca_model.json` -- `RegisterModelsDistanceMaps` — deformable registration via distance map matching (uses ANTs or ICON internally) +All classes inherit from `PhysioMotion4DBase` (`physiomotion4d_base.py`), which provides +a shared logger. Use `self.log_info()`, `self.log_debug()` — never `print()`. -### USD Pipeline +Consult `docs/API_MAP.md` for the full index of classes, methods, and signatures. +Regenerate it after any public API change: `py utils/generate_api_map.py` -Two APIs exist for VTK→USD conversion: +**Key data conventions:** +- Images: `itk.Image`, axes X, Y, Z [, T] in RAS world space +- 4D time series: shape `(X, Y, Z, T)` — never silently squeeze or permute axes +- Surfaces: `pv.PolyData` in RAS; converted to Y-up only at USD export +- Masks: ITK images with integer labels; consistent anatomy group IDs across all segmenters +- Transforms: ITK composite transforms stored in `.hdf` files +- State axis order and shape explicitly in every docstring and comment that touches arrays -1. **`ConvertVTKToUSD`** (`convert_vtk_to_usd.py`) — high-level, operates on PyVista objects in memory. Supports colormap overlays, multi-label anatomy, and animated time series. -2. **`vtk_to_usd/`** subpackage — file-based, modular. Core: `VTKToUSDConverter`, `ConversionSettings`, `MaterialData`. Use `convert_vtk_file()` for simple cases. +## Testing -`USDTools` and `USDAnatomyTools` handle USD stage merging, time-varying data preservation, and applying surgical materials from a materials library. +- Baselines in `tests/baselines/` via Git LFS — run `git lfs pull` after cloning +- `tests/conftest.py`: session-scoped fixtures chaining download → convert → segment → register +- `src/physiomotion4d/test_tools.py`: baseline comparison utilities (`TestTools`, etc.) +- Markers: `slow`, `requires_gpu`, `requires_data`, `experiment` (skipped by default) +- Prefer synthetic `itk.Image` / `pv.PolyData` over real data; keep volumes ≤64 voxels/side -### Key Data Conventions +## Working Process -- Medical images use ITK (`itk.Image`); surfaces use PyVista (`pv.PolyData`, `pv.UnstructuredGrid`) -- Coordinate system: RAS (medical) internally; converted to Y-up for USD/Omniverse export -- Masks are ITK images with integer labels; anatomy groups use consistent label IDs across segmenters -- Transforms stored as ITK composite transforms in `.hdf` files +Before editing any code: +1. Read the relevant source file(s) in full. +2. Summarize current behavior in 2–4 sentences. +3. Propose a numbered plan; confirm before implementing non-trivial changes. +4. Implement in small, reviewable diffs. +5. Update docstrings and tests for every changed public method. +6. Call out breaking changes explicitly. -### Testing +Breaking changes are acceptable. Backward-compatibility shims are not. -- Test baselines are stored in `tests/baselines/` via **Git LFS** — run `git lfs pull` after cloning -- `tests/conftest.py` provides session-scoped fixtures that chain (download → convert → segment → register); most tests depend on upstream fixtures -- Test markers: `slow`, `requires_gpu`, `requires_data`, `experiment` (skipped by default; use `--run-experiments`) -- `test_tools.py` (`src/physiomotion4d/test_tools.py`) provides baseline comparison utilities +## Agents and Skills -### Reference Code +Role-specific subagents live in `.claude/agents/`; slash-command skills in `.claude/skills/`. +See `AGENTS.md` for role-based guidance that applies across all AI tooling. -API documentation and examples for advanced third-party libraries (ITK, VTK, PyVista, Omniverse, PhysicsNeMo, Simpleware, MONAI, OpenUSD) are in the `reference_code/` directory. +- `/plan` — inspect files, summarize design, produce a numbered plan (no code changes) +- `/impl` — read → summarize → plan → implement in small diffs +- `/test-feature` — propose test plan, write synthetic-data pytest tests +- `/doc-feature` — update docstrings and regenerate API map ## File Operations -Use `git mv` / `git rm` for moving or deleting tracked files — not `mv` / `rm` — to preserve git history. +Use `git mv` / `git rm` — not `mv` / `rm` — to preserve history. ## Documentation Policy -Do **not** create new `.md` files unless explicitly requested. Document via docstrings and inline comments. A `README.md` may be created for new submodules that lack one. +Do **not** create new `.md` files unless explicitly requested. +Document via docstrings and inline comments. ## Code Style -- Single quotes for strings (`'...'`), double quotes for docstrings (`"""..."""`) -- Full type hints required (`mypy` is strict; `disallow_untyped_defs = true`) -- `Optional[X]` not `X | None` for ITK compatibility (ruff `UP007` is suppressed) -- Backward compatibility is **not** a priority — breaking changes are acceptable +- Single quotes for strings; double quotes for docstrings +- Full type hints (`mypy` strict; `disallow_untyped_defs = true`) +- `Optional[X]` not `X | None` (ruff `UP007` suppressed) +- Breaking changes are acceptable — backward compatibility is not a priority +- Max line length: 88 characters diff --git a/README.md b/README.md index fec42fb..c800e1a 100644 --- a/README.md +++ b/README.md @@ -587,11 +587,151 @@ pytest tests/ --cov=src/physiomotion4d --cov-report=html Tests automatically run on pull requests via GitHub Actions. See `tests/README.md` for detailed testing guide. +### Developer Tool Prerequisites + +| Tool | Required for | Install | +|------|-------------|---------| +| [Claude Code](https://claude.ai/code) | All `/plan`, `/impl`, `/test-feature`, `/doc-feature` skills and `claude_github_reviews.py` | `winget install Anthropic.ClaudeCode` | +| [gh CLI](https://cli.github.com) | `claude_github_reviews.py` | `winget install GitHub.cli`, then `gh auth login` | + +### AI-Assisted Development (Claude Code) + +The repository includes a complete [Claude Code](https://claude.ai/code) configuration +for contributors. It provides always-on project guidance, four specialized subagents, +and four slash-command skills tailored to this codebase. + +#### Configuration files + +| Path | Purpose | +|------|---------| +| `CLAUDE.md` | Always-on guidance: commands, architecture, code style, working process | +| `AGENTS.md` | Role-based rules for implementation, testing, docs, and architecture work | +| `.claude/agents/implementation.md` | Subagent: reads source, plans, implements in small diffs | +| `.claude/agents/testing.md` | Subagent: writes synthetic-data pytest tests | +| `.claude/agents/docs.md` | Subagent: updates docstrings and regenerates `docs/API_MAP.md` | +| `.claude/agents/architecture.md` | Subagent: design plans and trade-off analysis (no code written) | +| `.claude/skills/plan/SKILL.md` | `/plan` — inspect and plan before coding | +| `.claude/skills/impl/SKILL.md` | `/impl` — implement a feature or fix | +| `.claude/skills/test-feature/SKILL.md` | `/test-feature` — write tests for a module | +| `.claude/skills/doc-feature/SKILL.md` | `/doc-feature` — update docstrings and API map | + +#### Common contributor workflows + +**Planning a new feature before writing code** + +Use `/plan` to get an inspection of the affected classes, a numbered implementation +plan, and a list of open questions — without touching any files. + +```text +/plan add a confidence-weighted voting mode to SegmentChestEnsemble +``` + +Claude will read the relevant source, summarize current behavior, list files that +will change, and flag any coordinate-system or shape implications. + +--- + +**Implementing a feature or bug fix** + +Use `/impl` for end-to-end implementation: read → summarize → plan → diff → lint. + +```text +/impl add set_regularization_weight() to RegisterImagesANTs +``` + +```text +/impl fix the RAS-to-Y-up transform being applied twice in vtk_to_usd/usd_utils.py +``` + +Claude will read the affected module, propose a numbered plan, implement in the +smallest reviewable diff, update docstrings, run `ruff`, and call out breaking changes. + +--- + +**Writing tests for a new or changed module** + +Use `/test-feature` to get a test plan and a complete pytest file using synthetic +`itk.Image` or `pv.PolyData` objects — no real patient data required. + +```text +/test-feature ContourTools.extract_surface — test with a synthetic 32x32x32 sphere mask +``` + +```text +/test-feature RegisterImagesANTs with a pair of small synthetic ITK images +``` + +Claude will state image shapes and axis orders in every test docstring, mark +any real-data dependency with `@pytest.mark.requires_data`, and show the exact +run command. + +--- + +**Updating documentation after a change** + +Use `/doc-feature` after modifying a public API to refresh docstrings and regenerate +the API map. + +```text +/doc-feature update docstrings for RegisterImagesANTs after adding set_regularization_weight +``` + +Claude will update affected docstrings in NumPy style, add shape/axis annotations +where arrays are involved, and run `py utils/generate_api_map.py`. + +--- + +**Applying PR review suggestions (CodeRabbit / Copilot)** + +Use `claude_github_reviews.py` to fetch all review comments for a PR, have Claude +screen each one against `CLAUDE.md`, apply accepted edits as pending changes, and +write a Markdown summary to the repo root: + +```bash +py utils/claude_github_reviews.py --pr 42 +py utils/claude_github_reviews.py --pr 42 --dry-run # preview prompt only +py utils/claude_github_reviews.py --pr 42 --since-last-push --dry-run +``` + +When executing these from the repo root (including in automation), use the project +interpreter: `venv/Scripts/python` on Windows instead of `py`. + +Claude decides APPLY / REVISE / REJECT for each suggestion, with reasoning. +No changes are committed — review with `git diff`, then `git add -p`. + +--- + +**Setting up an isolated feature branch** + +Use the `setup_feature_worktree.py` utility to create a git worktree with its own +venv in one command (Windows): + +```bash +py utils/setup_feature_worktree.py my-feature +py utils/setup_feature_worktree.py my-feature --base-branch main +``` + +This creates a `feature/my-feature` branch, a sibling worktree directory, installs +`uv`, and installs project dependencies — ready to open in a separate editor window. + +--- + +**Architectural planning before a structural change** + +For larger changes, describe the goal to the architecture subagent and ask for a +design plan. Claude will produce the six-section format (current state → proposed +change → affected files → trade-offs → open questions → recommended next action) +without writing any code. + +```text +/plan redesign the segmentation return type to use a dataclass instead of a tuple +``` + ## 📖 Documentation - **API Documentation**: Comprehensive docstrings for all classes and methods - **Tutorial Notebooks**: Step-by-step examples in `experiments/` -- **CLAUDE.MD**: Development guidelines and architecture overview +- **CLAUDE.md / AGENTS.md**: Development guidelines, architecture overview, and Claude Code configuration ## 🤝 Contributing diff --git a/docs/API_MAP.md b/docs/API_MAP.md new file mode 100644 index 0000000..7f96948 --- /dev/null +++ b/docs/API_MAP.md @@ -0,0 +1,846 @@ +# API Map + +_Generated by `utils/generate_api_map.py`. Do not edit manually._ +_Re-run `py utils/generate_api_map.py` whenever public APIs change._ + +## docs/conf.py + +- **class Mock** (line 16) +- `def autodoc_skip_member(app, what, name, obj, skip, options)` (line 211): Custom function to skip certain members during autodoc processing. +- `def setup(app)` (line 219): Custom setup function for Sphinx. + +## experiments/Lung-GatedCT_To_USD/data_dirlab_4d_ct.py + +- **class DataDirLab4DCT** (line 10): This class is used to store the data for the DirLab 4DCT dataset. + - `def __init__(self)` (line 15): Define the variables specific to DirLab data + - `def get_case_names(self)` (line 30): Get the case names + - `def fix_image(self, input_image)` (line 34): Fix DirLab_4DCT intensities to conform to HU + +## src/physiomotion4d/cli/convert_ct_to_vtk.py + +- `def main()` (line 18): CLI entry point for CT to VTK conversion. + +## src/physiomotion4d/cli/convert_heart_gated_ct_to_usd.py + +- `def main()` (line 16): Command-line interface for Heart-gated CT processing. + +## src/physiomotion4d/cli/convert_vtk_to_usd.py + +- `def main()` (line 30): Command-line interface for VTK to USD conversion. + +## src/physiomotion4d/cli/create_statistical_model.py + +- `def main()` (line 23): Command-line interface for create statistical model workflow. + +## src/physiomotion4d/cli/fit_statistical_model_to_patient.py + +- `def main()` (line 22): Command-line interface for heart model to patient registration. + +## src/physiomotion4d/cli/reconstruct_highres_4d_ct.py + +- `def main()` (line 21): Command-line interface for high-resolution 4D CT reconstruction. + +## src/physiomotion4d/cli/visualize_pca_modes.py + +- `def main()` (line 92): Command-line interface for visualizing PCA modes. + +## src/physiomotion4d/contour_tools.py + +- **class ContourTools** (line 18): Tools for creating and manipulating contours. + - `def __init__(self, log_level=logging.INFO)` (line 23): Initialize ContourTools. + - `def extract_contours(self, mask_image)` (line 31): Make contours from a mask image. + - `def transform_contours(self, contours, tfm, with_deformation_magnitude=False)` (line 73): Transform contours using a given transform. + - `def merge_meshes(self, meshes)` (line 94): Merge multiple fixed meshes into a single mesh. + - `def create_reference_image(self, mesh, spatial_resolution=0.5, buffer_factor=0.25, ptype=itk.F)` (line 137): Create a reference image from a mesh. + - `def create_mask_from_mesh(self, mesh, reference_image)` (line 166) + - `def create_distance_map(self, mesh, reference_image, squared_distance=False, negative_inside=True, zero_inside=False, norm_to_max_distance=0.0)` (line 255) + - `def create_deformation_field(self, points, point_displacements, reference_image, blur_sigma=2.5, ptype=itk.D)` (line 322): Create a displacement map from model points and displacements. + +## src/physiomotion4d/convert_nrrd_4d_to_3d.py + +- **class ConvertNRRD4DTo3D** (line 11) + - `def __init__(self, log_level=logging.INFO)` (line 12): Initialize the NRRD 4D to 3D converter. + - `def load_nrrd_3d(self, filenames)` (line 22) + - `def load_nrrd_4d(self, filename)` (line 28) + - `def get_3d_image(self, index)` (line 62) + - `def get_number_of_3d_images(self)` (line 65) + - `def save_3d_images(self, basename)` (line 68) + +## src/physiomotion4d/convert_vtk_to_usd.py + +- **class ConvertVTKToUSD** (line 36): Advanced VTK to USD converter with colormap and anatomical labeling support. + - `def __init__(self, data_basename, input_polydata, mask_ids=None, compute_normals=False, convert_to_surface=True, times_per_second=24.0, log_level=logging.INFO)` (line 66): Initialize converter. + - `def supports_mesh_type(self, mesh)` (line 119): Check if mesh type is supported for conversion. + - `def list_available_arrays(self)` (line 147): List all point data arrays available across all time steps. + - `def set_colormap(self, color_by_array=None, colormap='plasma', intensity_range=None)` (line 193): Configure colormap for visualization. + - `def convert(self, output_usd_file, convert_to_surface=None, compute_normals=None)` (line 227): Convert VTK meshes to USD. + +## src/physiomotion4d/image_tools.py + +- **class ImageTools** (line 19): Utilities for medical image format conversions and processing. + - `def __init__(self, log_level=logging.INFO)` (line 35): Initialize ImageTools. + - `def imreadVD3(self, filename)` (line 43): Read an ITK vector image with double precision vectors. + - `def imwriteVD3(self, image, filename, compression=True)` (line 69): Write an ITK vector image with double precision vectors. + - `def convert_itk_image_to_sitk(self, itk_image)` (line 93): Convert an ITK image to a SimpleITK image. + - `def convert_sitk_image_to_itk(self, sitk_image)` (line 151): Convert a SimpleITK image to an ITK image. + - `def convert_array_to_image_of_vectors(self, arr_data, reference_image, ptype=itk.D)` (line 218): Convert a numpy array to an ITK image of vector type. + - `def flip_image(self, in_image, in_mask=None, flip_x=False, flip_y=False, flip_z=False, flip_and_make_identity=False)` (line 249): Flip the image and mask. + +## src/physiomotion4d/network_weights/vista3d/hugging_face_pipeline.py + +- **class HuggingFacePipelineHelper** (line 7) + - `def __init__(self, pipeline_name='vista3d')` (line 9) + - `def get_pipeline(self)` (line 18) + - `def init_pipeline(self, pretrained_model_name_or_path, **kwargs)` (line 30) + +## src/physiomotion4d/network_weights/vista3d/scripts/early_stop_score_function.py + +- `def score_function(engine)` (line 7) + +## src/physiomotion4d/network_weights/vista3d/scripts/evaluator.py + +- **class Vista3dEvaluator** (line 39): Supervised detection evaluation method with image and label, inherits from ``SupervisedEvaluator`` and ``Workflow``. + - `def __init__(self, device, val_data_loader, network, epoch_length=None, non_blocking=False, prepare_batch=default_prepare_batch, iteration_update=None, inferer=None, postprocessing=None, key_val_metric=None, additional_metrics=None, metric_cmp_fn=default_metric_cmp_fn, val_handlers=None, amp=False, mode=ForwardMode.EVAL, event_names=None, event_to_attr=None, decollate=True, to_kwargs=None, amp_kwargs=None, hyper_kwargs=None)` (line 85) + - `def transform_points(self, point, affine)` (line 137): transform point to the coordinates of the transformed image + - `def check_prompts_format(self, label_prompt, points, point_labels)` (line 148): check the format of user prompts + +## src/physiomotion4d/network_weights/vista3d/scripts/inferer.py + +- **class Vista3dInferer** (line 21): Vista3D Inferer + - `def __init__(self, roi_size, overlap, use_point_window=False, sw_batch_size=1)` (line 30) + +## src/physiomotion4d/network_weights/vista3d/scripts/trainer.py + +- **class Vista3dTrainer** (line 39): Supervised detection training method with image and label, inherits from ``Trainer`` and ``Workflow``. + - `def __init__(self, device, max_epochs, train_data_loader, network, optimizer, loss_function, epoch_length=None, non_blocking=False, prepare_batch=default_prepare_batch, iteration_update=None, inferer=None, postprocessing=None, key_train_metric=None, additional_metrics=None, metric_cmp_fn=default_metric_cmp_fn, train_handlers=None, amp=False, event_names=None, event_to_attr=None, decollate=True, optim_set_to_none=False, to_kwargs=None, amp_kwargs=None, hyper_kwargs=None)` (line 88) + +## src/physiomotion4d/network_weights/vista3d/vista3d_config.py + +- **class VISTA3DConfig** (line 4): Configuration class for vista3d + - `def __init__(self, encoder_embed_dim=48, input_channels=1, **kwargs)` (line 9): Set the hyperparameters for the VISTA3D model. + +## src/physiomotion4d/network_weights/vista3d/vista3d_model.py + +- **class VISTA3DModel** (line 9): VISTA3D model for hugging face + - `def __init__(self, config)` (line 14) + - `def forward(self, input)` (line 22) +- `def register_my_model()` (line 26): Utility function to register VISTA3D model so that it can be instantiate by the AutoModel function. + +## src/physiomotion4d/network_weights/vista3d/vista3d_pipeline.py + +- **class VISTA3DPipeline** (line 48): Define the VISTA3D pipeline. + - `def __init__(self, model, **kwargs)` (line 80) + - `def check_prompts_format(self, label_prompt, points, point_labels)` (line 213): check the format of user prompts + - `def transform_points(self, point, affine)` (line 287): transform point to the coordinates of the transformed image + - `def preprocess(self, inputs, **kwargs)` (line 298) + - `def postprocess(self, outputs, **kwargs)` (line 435) +- `def register_simple_pipeline()` (line 454) + +## src/physiomotion4d/notebook_utils.py + +- `def running_as_test()` (line 11): True when the notebook is run as a test (e.g. by pytest experiment tests). + +## src/physiomotion4d/physiomotion4d_base.py + +- **class ClassNameFilter** (line 38): Filter to show logs only from specific class names. + - `def __init__(self)` (line 51) + - `def filter(self, record)` (line 56): Filter log records based on class name. +- **class PhysioMotion4DBase** (line 68): Base class providing standardized logging and debug settings. + - `def __init__(self, class_name=None, log_level=logging.INFO, log_to_file=None)` (line 106): Initialize the base class with logging configuration. + - `def set_log_level(cls, log_level)` (line 197): Set the logging level for all PhysioMotion4D classes. + - `def set_log_classes(cls, class_names)` (line 223): Set which classes should show their logging output. + - `def set_log_all_classes(cls)` (line 242): Enable logging output from all PhysioMotion4D classes. + - `def get_log_classes(cls)` (line 256): Get the list of classes currently showing logs. + - `def log_debug(self, message, *args)` (line 272): Log a debug message with optional %-style formatting. + - `def log_info(self, message, *args)` (line 285): Log an info message with optional %-style formatting. + - `def log_warning(self, message, *args)` (line 298): Log a warning message with optional %-style formatting. + - `def log_error(self, message, *args)` (line 311): Log an error message with optional %-style formatting. + - `def log_critical(self, message, *args)` (line 324): Log a critical message with optional %-style formatting. + - `def log_section(self, title, *args, width=70, char='=')` (line 362): Log a formatted section header with optional %-style formatting. + - `def log_progress(self, current, total, prefix='Progress')` (line 390): Log progress information. + +## src/physiomotion4d/register_images_ants.py + +- **class RegisterImagesANTs** (line 25): ANTs-based deformable image registration implementation. + - `def __init__(self, log_level=logging.INFO)` (line 71): Initialize the ANTs image registration class. + - `def set_number_of_iterations(self, number_of_iterations)` (line 86): Set the number of iterations for ANTs registration. + - `def set_transform_type(self, transform_type)` (line 95): Set the type of transform to use for registration. + - `def set_metric(self, metric)` (line 107): Set the similarity metric to use for registration. + - `def itk_affine_transform_to_ants_transform(self, itk_tfm)` (line 317): Convert ITK affine/rigid transform to ANTs affine transform. + - `def itk_transform_to_antsfile(self, itk_tfm, reference_image, output_filename)` (line 410): Convert ITK transform to ANTs transform file. + - `def registration_method(self, moving_image, moving_mask=None, moving_image_pre=None, initial_forward_transform=None)` (line 510): Register moving image to fixed image using ANTs registration algorithm. + +## src/physiomotion4d/register_images_base.py + +- **class RegisterImagesBase** (line 29): Base class for deformable image registration algorithms. + - `def __init__(self, log_level=logging.INFO)` (line 75): Initialize the base image registration class. + - `def set_modality(self, modality)` (line 106): Set the imaging modality for registration optimization. + - `def set_fixed_image(self, fixed_image)` (line 122): Set the fixed/target image for registration. + - `def set_mask_dilation(self, mask_dilation_mm)` (line 143): Set the dilation of the fixed and moving image masks. + - `def set_fixed_mask(self, fixed_mask)` (line 151): Set a binary mask for the fixed image region of interest. + - `def preprocess(self, image, modality='ct')` (line 192): Preprocess the image based on modality-specific requirements. + - `def registration_method(self, moving_image, moving_mask=None, moving_image_pre=None, initial_forward_transform=None)` (line 213): Main registration method to align moving image to fixed image. + - `def register(self, moving_image, moving_mask=None, moving_image_pre=None, initial_forward_transform=None)` (line 246): Register a moving image to the fixed image. + - `def get_registered_image(self)` (line 329): Get the registered image. + +## src/physiomotion4d/register_images_greedy.py + +- **class RegisterImagesGreedy** (line 39): Greedy-based deformable image registration implementation. + - `def __init__(self, log_level=logging.INFO)` (line 67): Initialize the Greedy image registration class. + - `def set_number_of_iterations(self, number_of_iterations)` (line 80): Set the number of iterations per resolution level. + - `def set_transform_type(self, transform_type)` (line 88): Set the type of transform: Deformable, Affine, or Rigid. + - `def set_metric(self, metric)` (line 99): Set the similarity metric (CC→NCC, Mattes→NMI, MeanSquares→SSD). + - `def registration_method(self, moving_image, moving_mask=None, moving_image_pre=None, initial_forward_transform=None)` (line 260): Register moving image to fixed image using Greedy. + +## src/physiomotion4d/register_images_icon.py + +- **class RegisterImagesICON** (line 25): ICON-based deformable image registration implementation. + - `def __init__(self, log_level=logging.INFO)` (line 61): Initialize the ICON image registration class. + - `def set_weights_path(self, weights_path)` (line 79): Set a custom weights file for the uniGradICON network. + - `def set_number_of_iterations(self, number_of_iterations)` (line 93): Set the number of iterations for ICON registration. + - `def set_multi_modality(self, enable)` (line 101): Enable or disable multi-modality registration. + - `def set_mass_preservation(self, enable)` (line 118): Enable or disable mass preservation constraint. + - `def preprocess(self, image, modality='ct')` (line 135): Preprocess the image for ICON registration. + - `def registration_method(self, moving_image, moving_mask=None, moving_image_pre=None, initial_forward_transform=None)` (line 155): Register moving image to fixed image using ICON registration algorithm. + +## src/physiomotion4d/register_models_distance_maps.py + +- **class RegisterModelsDistanceMaps** (line 61): Register anatomical models using mask-based deformable registration. + - `def __init__(self, moving_model, fixed_model, reference_image, roi_dilation_mm=20, log_level=logging.INFO)` (line 118): Initialize mask-based model registration. + - `def register(self, transform_type='Deformable', use_icon=False, icon_iterations=50)` (line 225): Perform mask-based registration of moving model to fixed model. + +## src/physiomotion4d/register_models_icp.py + +- **class RegisterModelsICP** (line 52): Register anatomical models using Iterative Closest Point (ICP) algorithm. + - `def __init__(self, fixed_model, log_level=logging.INFO)` (line 100): Initialize ICP-based model registration. + - `def register(self, moving_model, transform_type='Affine', max_iterations=2000)` (line 130): Perform ICP alignment of moving model to fixed model. + +## src/physiomotion4d/register_models_icp_itk.py + +- **class RegisterModelsICPITK** (line 14): Register shape models using model to distance map minimization. + - `def __init__(self, fixed_model, reference_image=None, point_subsample_step=4, log_level=logging.INFO)` (line 39): Initialize the ICP-ITK model registration. + - `def set_reference_image(self, reference_image)` (line 121): Set the reference image for registration. + - `def set_fixed_model(self, fixed_model)` (line 132): Set the average model for registration. + - `def register(self, moving_model, initial_transform=None, transform_type='Affine', method='L-BFGS-B', scale_bound=0.2, skew_bound=0.03, versor_bound=0.15, translation_bound=15, max_iterations=500)` (line 270): Optimize affine alignment to minimize mean distance. + +## src/physiomotion4d/register_models_pca.py + +- **class RegisterModelsPCA** (line 17): Register PCA-based shape models to medical images using mean distance optimization. + - `def __init__(self, pca_template_model, pca_eigenvectors, pca_std_deviations, pca_number_of_modes=0, pca_template_model_point_subsample=4, pre_pca_transform=None, fixed_distance_map=None, fixed_model=None, reference_image=None, log_level=logging.INFO)` (line 75): Initialize the PCA-based model-to-image registration. + - `def from_json(cls, pca_template_model, pca_json_filename, pca_number_of_modes=0, pca_template_model_point_subsample=4, pre_pca_transform=None, fixed_distance_map=None, fixed_model=None, reference_image=None, log_level=logging.INFO)` (line 181): Create RegisterModelsPCA from PCA model JSON file. + - `def from_pca_model(cls, pca_template_model, pca_model, pca_number_of_modes=0, pca_template_model_point_subsample=4, pre_pca_transform=None, fixed_distance_map=None, fixed_model=None, reference_image=None, log_level=logging.INFO)` (line 288): Create RegisterModelsPCA from a PCA model dictionary. + - `def set_fixed_model(self, fixed_model, reference_image)` (line 369): Set the fixed model for registration. + - `def set_fixed_distance_map(self, fixed_distance_map)` (line 390): Set the reference image for registration. + - `def set_pca_template_model(self, pca_template_model)` (line 401): Set the average model for registration. + - `def transform_template_model(self)` (line 632): Create the final registered model by applying PCA deformation. + - `def transform_point(self, point, include_pre_pca_transform=True)` (line 695): Transform an arbitrary point using nearest neighbor interpolation. + - `def compute_pca_transforms(self, reference_image)` (line 729): Compute PCA transforms. + - `def register(self, pca_number_of_modes=0, pca_coefficient_bounds=3.5, method='L-BFGS-B', max_iterations=100)` (line 766): Optimize PCA coefficients to deform the model to better match + +## src/physiomotion4d/register_time_series_images.py + +- **class RegisterTimeSeriesImages** (line 23): Register a time series of images to a fixed image. + - `def __init__(self, registration_method='ants', log_level=logging.INFO)` (line 77): Initialize the time series image registration class. + - `def set_number_of_iterations_ants(self, number_of_iterations_ants)` (line 110): Set the number of iterations for ANTs registration. + - `def set_number_of_iterations_icon(self, number_of_iterations_icon)` (line 121): Set the number of iterations for ICON registration. + - `def set_smooth_prior_transform_sigma(self, smooth_prior_transform_sigma)` (line 129): Set the sigma for smoothing the prior transform. + - `def set_mask_dilation(self, mask_dilation_mm)` (line 139): Set the dilation of the fixed and moving image masks. + - `def set_modality(self, modality)` (line 149): Set the imaging modality for registration optimization. + - `def set_fixed_image(self, fixed_image)` (line 159): Set the fixed image for registration. + - `def set_fixed_mask(self, fixed_mask)` (line 170): Set a binary mask for the fixed image region of interest. + - `def register_time_series(self, moving_images, moving_masks=None, reference_frame=0, register_reference=True, prior_weight=0.0)` (line 180): Register a time series of images to the fixed image. + - `def reconstruct_time_series(self, moving_images, inverse_transforms, upsample_to_fixed_resolution=False)` (line 500): Reconstruct time series images using inverse transforms. + - `def registration_method(self, moving_image, moving_mask=None, moving_image_pre=None, initial_forward_transform=None)` (line 630): Registration method required by RegisterImagesBase. + +## src/physiomotion4d/segment_anatomy_base.py + +- **class SegmentAnatomyBase** (line 18): Base class for anatomy segmentation that provides common functionality for + - `def __init__(self, log_level=logging.INFO)` (line 45): Initialize the SegmentAnatomyBase class. + - `def set_other_and_all_mask_ids(self)` (line 78): Set the other mask IDs and consolidate all mask ID dictionaries. + - `def set_target_spacing(self, target_spacing)` (line 111): Set the target isotropic spacing for image resampling. + - `def preprocess_input(self, input_image)` (line 123): Preprocess the input image for segmentation. + - `def postprocess_labelmap(self, labelmap_image, input_image)` (line 245): Resample the labelmap to match the input image spacing. + - `def segment_connected_component(self, preprocessed_image, labelmap_image, lower_threshold, upper_threshold, labelmap_ids=None, mask_id=0, use_mid_slice=True, hole_fill=2)` (line 341): Segment connected components based on intensity thresholding. + - `def segment_contrast_agent(self, preprocessed_image, labelmap_image)` (line 440): Include contrast-enhanced blood in the labelmap. + - `def create_anatomy_group_masks(self, labelmap_image)` (line 482): Create binary masks for different anatomical groups from the labelmap. + - `def segmentation_method(self, preprocessed_image)` (line 568): Abstract method for image segmentation - must be implemented by subclasses. + - `def dilate_mask(self, mask, dilation)` (line 590): Dilate a binary mask using morphological operations. + - `def segment(self, input_image, contrast_enhanced_study=False)` (line 613): Perform complete chest CT segmentation. + +## src/physiomotion4d/segment_chest_ensemble.py + +- **class SegmentChestEnsemble** (line 20): A class that inherits from physioSegmentChest and implements the + - `def __init__(self, log_level=logging.INFO)` (line 26): Initialize the vista3d class. + - `def ensemble_segmentation(self, labelmap_vista, labelmap_totseg)` (line 309): Combine two segmentation results using label mapping and priority rules. + - `def segmentation_method(self, preprocessed_image)` (line 398): Run VISTA3D on the preprocessed image and return result. + +## src/physiomotion4d/segment_chest_total_segmentator.py + +- **class SegmentChestTotalSegmentator** (line 21): Chest CT segmentation using TotalSegmentator deep learning model. + - `def __init__(self, log_level=logging.INFO)` (line 57): Initialize the TotalSegmentator-based chest segmentation. + - `def segmentation_method(self, preprocessed_image)` (line 199): Run TotalSegmentator on the preprocessed image and return result. + +## src/physiomotion4d/segment_chest_vista_3d.py + +- **class SegmentChestVista3D** (line 32): Chest CT segmentation using NVIDIA VISTA-3D foundational model. + - `def __init__(self, log_level=logging.INFO)` (line 68): Initialize the VISTA-3D based chest segmentation. + - `def set_label_prompt(self, label_prompt)` (line 232): Set specific anatomical structure labels to segment. + - `def set_whole_image_segmentation(self)` (line 253): Configure for automatic whole-image segmentation. + - `def segment_soft_tissue(self, preprocessed_image, labelmap_image)` (line 269): Add soft tissue segmentation to fill gaps in VISTA-3D output. + - `def preprocess_input(self, input_image)` (line 302): Preprocess the input image for VISTA-3D segmentation. + - `def segmentation_method(self, preprocessed_image)` (line 329): Run VISTA-3D segmentation on the preprocessed image. + +## src/physiomotion4d/segment_chest_vista_3d_nim.py + +- **class SegmentChestVista3DNIM** (line 25): A class that inherits from physioSegmentChest and implements the + - `def __init__(self, log_level=logging.INFO)` (line 31): Initialize the vista3d class. + - `def segmentation_method(self, preprocessed_image)` (line 45): Run VISTA3D on the preprocessed image using the NIM and return result. + +## src/physiomotion4d/segment_heart_simpleware.py + +- **class SegmentHeartSimpleware** (line 23): Heart CT segmentation using Simpleware Medical's ASCardio module. + - `def __init__(self, log_level=logging.INFO)` (line 56): Initialize the Simpleware Medical based heart segmentation. + - `def set_trim_mask_to_essentials(self, trim_mask)` (line 118): Set whether to trim mask to common and critical structures. + - `def set_simpleware_executable_path(self, path)` (line 126): Set the path to the Simpleware Medical console executable. + - `def segmentation_method(self, preprocessed_image)` (line 139): Run Simpleware Medical ASCardio segmentation on the preprocessed image. + - `def get_landmarks(self)` (line 327): Get the landmarks. + - `def trim_mask_to_essentials(self, labelmap_image)` (line 331): Trim mask to essentials. + +## src/physiomotion4d/test_tools.py + +- `def set_create_baseline_if_missing(value)` (line 28): Set whether to create baseline files when missing (used by pytest conftest). +- **class TestTools** (line 34): Utilities for pytest image comparison: baseline directory, results directory, + - `def __init__(self, results_dir, baselines_dir, class_name, *, log_level=logging.INFO)` (line 41) + - `def compare_2d_to_3d_slice(self, image_2d, image_3d, slice_index, axis=0, *, per_pixel_absolute_error_tol=0.0, max_number_of_pixels_above_tol=0, total_absolute_error_tol=0.0)` (line 75): Compare a 2D itk.Image to a slice of a 3D itk.Image. Converts to numpy only for computing differences. + - `def image_pass_fail_and_pixels_above_tolerance(self)` (line 149): Return (pass, value) for number of pixels above tolerance from the most recent compare_2d_to_3d_slice call. + - `def image_pass_fail_and_total_absolute_error(self)` (line 163): Return (pass, value) for total absolute error from the most recent compare_2d_to_3d_slice call. + - `def image_difference(self)` (line 177): Return the difference image (itk.Image) from the most recent compare_2d_to_3d_slice call. + - `def transform_pass_fail_and_number_of_values_above_tolerance(self)` (line 183): Return (pass, value) for number of values above tolerance from the most recent compare_result_to_baseline_transform call. + - `def transform_pass_fail_and_total_absolute_error(self)` (line 199): Return (pass, value) for total absolute error from the most recent compare_result_to_baseline_transform call. + - `def transform_difference(self)` (line 213): Return the difference transform (itk.Transform) from the most recent compare_result_to_baseline_transform call. + - `def write_result_image(self, image, filename)` (line 219): Write the image to the results directory. + - `def write_result_transform(self, transform, filename)` (line 223): Write the transform to the results directory. + - `def compare_result_to_baseline_transform(self, filename, *, per_value_absolute_error_tol=0.0, max_number_of_values_above_tol=0, total_absolute_error_tol=0.0)` (line 229): Compare the transform to the baseline transform. + - `def compare_result_to_baseline_image(self, filename, slice_index=None, axis=0, *, per_pixel_absolute_error_tol=0.0, max_number_of_pixels_above_tol=0, total_absolute_error_tol=0.0)` (line 307): Load 3D image from results_filename and 2D baseline from baseline_filename (.mha), compare the given slice to baseline, + +## src/physiomotion4d/transform_tools.py + +- **class TransformTools** (line 33): Utilities for transforming and manipulating ITK transforms. + - `def __init__(self, log_level=logging.INFO)` (line 66): Initialize the TransformTools class. + - `def combine_displacement_field_transforms(self, tfm1, tfm2, reference_image, tfm1_weight=1.0, tfm2_weight=1.0, mode='compose', tfm1_blur_sigma=0.0, tfm2_blur_sigma=0.0)` (line 74): Compose two displacement field transforms. + - `def convert_transform_to_displacement_field(self, tfm, reference_image, np_component_type=np.float64, use_reference_image_as_mask=False)` (line 158): Generate a dense deformation field from an ITK transform. + - `def convert_transform_to_displacement_field_transform(self, tfm, reference_image)` (line 246): Convert an ITK transform to a displacement field transform. + - `def invert_displacement_field_transform(self, tfm)` (line 263): Invert a displacement field transform. + - `def transform_pvcontour(self, contour, tfm, with_deformation_magnitude=False)` (line 285): Transform PyVista contour meshes using an ITK transform. + - `def transform_image(self, img, tfm, reference_image, interpolation_method='linear')` (line 348): Transform an ITK image using a specified transform and interpolation. + - `def convert_vtk_matrix_to_itk_transform(self, vtk_mat)` (line 425): Convert a VTK matrix to an ITK transform. + - `def smooth_transform(self, tfm, sigma, reference_image)` (line 460): Smooth a transform using Gaussian filtering to reduce noise. + - `def combine_transforms_with_masks(self, transform1, transform2, mask1, mask2, reference_image, max_iter=10, jacobian_threshold=0.1)` (line 519): Combine two transforms using spatial masks with folding correction. + - `def compute_jacobian_determinant_from_field(self, field)` (line 609): Compute Jacobian determinant of a displacement field. + - `def detect_folding_in_field(self, jacobian_det, threshold=0.1)` (line 638): Detect spatial folding in a transform. + - `def reduce_folding_in_field(self, field, jacobian_det, reduction_factor=0.8, threshold=0.1)` (line 662): Reduce folding by scaling displacement field in problematic regions. + - `def generate_grid_image(self, reference_image, grid_size=60, line_width=3)` (line 701): Generate a grid image. + - `def convert_field_to_grid_visualization(self, tfm, reference_image, grid_size=60, line_width=3)` (line 734): Generate a visual deformation grid for transform visualization. + - `def convert_itk_transform_to_usd_visualization(self, tfm, reference_image, output_filename, visualization_type='arrows', subsample_factor=4, arrow_scale=1.0, magnitude_threshold=0.0)` (line 770): Convert an ITK transform to a USD visualization for NVIDIA Omniverse. + +## src/physiomotion4d/usd_anatomy_tools.py + +- **class USDAnatomyTools** (line 14): This class is used to enhance the appearance of anatomy meshes in a USD + - `def __init__(self, stage, log_level=logging.INFO)` (line 20): Initialize USDAnatomyTools. + - `def get_anatomy_types(self)` (line 195): Return list of supported anatomy type names for apply_anatomy_material_to_mesh. + - `def get_anatomy_diffuse_color(self, anatomy_type)` (line 199): Return the diffuse reflection RGB color for the given anatomy type. + - `def apply_anatomy_material_to_mesh(self, mesh_path, anatomy_type)` (line 226): Apply an anatomic OmniSurface material to a single mesh prim by type. + - `def apply_anatomy_material_to_prim(self, prim, material_params)` (line 250): Corrected material application with Omniverse-specific fixes + - `def enhance_meshes(self, segmentator)` (line 321): Find and enhance all heart meshes + +## src/physiomotion4d/usd_tools.py + +- **class USDTools** (line 23): Utilities for manipulating Universal Scene Description (USD) files. + - `def __init__(self, log_level=logging.INFO)` (line 59): Initialize the USDTools class. + - `def get_subtree_bounding_box(self, prim)` (line 67): Compute the axis-aligned bounding box of a USD primitive subtree. + - `def save_usd_file_arrangement(self, new_stage_name, usd_file_names)` (line 127): Create a spatial grid arrangement of objects from multiple USD files. + - `def merge_usd_files(self, output_filename, input_filenames_list)` (line 250): Merge multiple USD files into a single comprehensive USD file. + - `def merge_usd_files_flattened(self, output_filename, input_filenames_list)` (line 441): Merge multiple USD files using references and flattening. + - `def list_mesh_primvars(self, stage_or_path, mesh_path, time_code=None)` (line 566): List all primvars on a USD mesh with metadata. + - `def pick_color_primvar(self, primvar_infos, keywords=('strain', 'stress'))` (line 661): Select a primvar for coloring based on keywords and preferences. + - `def apply_colormap_from_primvar(self, stage_or_path, mesh_path, source_primvar, *, cmap='viridis', time_codes=None, intensity_range=None, use_sigmoid_scale=False, write_default_at_t0=True, bind_vertex_color_material=True)` (line 715): Apply colormap visualization by converting a primvar to displayColor. + - `def set_solid_display_color(self, stage_or_path, mesh_path, color, *, time_codes=None, bind_vertex_color_material=True)` (line 1010): Set a constant (solid) displayColor for a mesh. + - `def list_mesh_paths_under(self, stage_or_path, parent_path='/World/Meshes')` (line 1103): List paths of all mesh prims under a parent path. + - `def repair_mesh_primvar_element_sizes(self, stage_or_path, mesh_path, *, time_code=None, save=True)` (line 1130): Repair missing/incorrect primvar elementSize metadata for a mesh. + +## src/physiomotion4d/vtk_to_usd/converter.py + +- **class VTKToUSDConverter** (line 21): High-level converter for VTK files to USD. + - `def __init__(self, settings=None)` (line 37): Initialize converter. + - `def convert_file(self, vtk_file, output_usd, mesh_name='Mesh', material=None, extract_surface=True)` (line 48): Convert a single VTK file to USD. + - `def convert_files_static(self, vtk_files, output_usd, mesh_name='Mesh', material=None, extract_surface=True)` (line 114): Convert multiple VTK files into one static USD stage (no time samples). + - `def convert_sequence(self, vtk_files, output_usd, mesh_name='Mesh', time_codes=None, material=None, extract_surface=True)` (line 190): Convert a sequence of VTK files to time-varying USD. + - `def convert_mesh_data(self, mesh_data, output_usd, mesh_name='Mesh', material=None)` (line 316): Convert MeshData directly to USD. + - `def convert_mesh_data_sequence(self, mesh_data_sequence, output_usd, mesh_name='Mesh', time_codes=None, material=None)` (line 378): Convert sequence of MeshData to time-varying USD. +- `def convert_vtk_file(vtk_file, output_usd, settings=None, **kwargs)` (line 555): Convenience function to convert a single VTK file. +- `def convert_vtk_sequence(vtk_files, output_usd, settings=None, **kwargs)` (line 576): Convenience function to convert a sequence of VTK files. + +## src/physiomotion4d/vtk_to_usd/data_structures.py + +- **class DataType** (line 13): Data type enumeration for generic arrays. +- **class GenericArray** (line 29): Generic data array that can be converted to USD primvar. +- **class MaterialData** (line 79): Material properties for USD conversion. +- **class MeshData** (line 97): Mesh geometry data for USD conversion. +- **class VolumeData** (line 119): Volume data for USD conversion. +- **class TimeStepData** (line 133): Data for a single time step. +- **class ConversionSettings** (line 143): Settings for VTK to USD conversion. + +## src/physiomotion4d/vtk_to_usd/material_manager.py + +- **class MaterialManager** (line 16): Manages creation and binding of USD materials. + - `def __init__(self, stage, materials_scope_path='/World/Looks')` (line 23): Initialize material manager. + - `def create_material(self, mat_data, time_code=None)` (line 37): Create a UsdPreviewSurface material. + - `def bind_material(self, geom_prim, material)` (line 133): Bind a material to a geometry prim. + - `def get_or_create_material(self, mat_data, time_code=None)` (line 149): Get existing material from cache or create new one. + - `def create_default_material(self, name='default', color=(0.8, 0.8, 0.8))` (line 165): Create a simple default material. + +## src/physiomotion4d/vtk_to_usd/mesh_utils.py + +- `def cell_type_name_for_vertex_count(count)` (line 27): Return a readable name for a cell type given its vertex count. +- `def split_mesh_data_by_cell_type(mesh_data, mesh_name)` (line 32): Split MeshData into one mesh per distinct face vertex count (cell type). +- `def split_mesh_data_by_connectivity(mesh_data, mesh_name)` (line 280): Split MeshData into one mesh per connected component. + +## src/physiomotion4d/vtk_to_usd/usd_mesh_converter.py + +- **class UsdMeshConverter** (line 25): Converts MeshData to UsdGeomMesh with full feature support. + - `def __init__(self, stage, settings, material_mgr)` (line 36): Initialize mesh converter. + - `def create_mesh(self, mesh_data, mesh_path, time_code=None, bind_material=True)` (line 53): Create a UsdGeomMesh from MeshData. + - `def create_time_varying_mesh(self, mesh_data_sequence, mesh_path, time_codes, bind_material=True)` (line 282): Create a mesh with time-varying attributes. + +## src/physiomotion4d/vtk_to_usd/usd_utils.py + +- `def ras_to_usd(point)` (line 18): Convert RAS (Right-Anterior-Superior) coordinates to USD's right-handed Y-up system. +- `def ras_points_to_usd(points)` (line 45): Convert array of RAS points to USD coordinates. +- `def ras_normals_to_usd(normals)` (line 67): Convert array of RAS normals to USD coordinates. +- `def numpy_to_vt_array(array, data_type)` (line 81): Convert numpy array to appropriate VtArray type. +- `def get_sdf_value_type(data_type, num_components)` (line 153): Get appropriate SDF value type for primvar creation. +- `def sanitize_primvar_name(name)` (line 200): Sanitize a name to be USD-compliant. +- `def create_primvar(geom, array, array_name_prefix='', time_code=None)` (line 235): Create a USD primvar from a GenericArray. +- `def triangulate_face(face_counts, face_indices)` (line 349): Triangulate polygonal faces. +- `def compute_mesh_extent(points)` (line 389): Compute bounding box extent for a mesh. + +## src/physiomotion4d/vtk_to_usd/vtk_reader.py + +- **class VTKReader** (line 20): Base class for VTK file readers. +- **class PolyDataReader** (line 222): Reader for VTK PolyData files (.vtp). + - `def read(filename)` (line 226): Read a VTP file and return MeshData. +- **class LegacyVTKReader** (line 282): Reader for legacy VTK files (.vtk). + - `def read(filename, extract_surface=True)` (line 294): Read a legacy VTK file and return MeshData. +- **class UnstructuredGridReader** (line 455): Reader for VTK UnstructuredGrid files (.vtu). + - `def read(filename, extract_surface=True)` (line 459): Read a VTU file and return MeshData. +- `def read_vtk_file(filename, extract_surface=True)` (line 568): Auto-detect VTK file format and read appropriately. +- `def validate_time_series_topology(mesh_data_sequence, filenames=None)` (line 596): Validate topology consistency across a time series of meshes. + +## src/physiomotion4d/workflow_convert_ct_to_vtk.py + +- **class WorkflowConvertCTToVTK** (line 59): Segment a CT image and produce per-anatomy-group VTK surfaces and meshes. + - `def __init__(self, segmentation_method='total_segmentator', log_level=logging.INFO)` (line 100): Initialize the workflow. + - `def run_workflow(self, input_image, contrast_enhanced_study=False, anatomy_groups=None)` (line 247): Segment the CT image and extract per-anatomy-group VTK objects. + - `def save_surfaces(surfaces, output_dir, prefix='')` (line 350): Save each group surface to its own VTP file. + - `def save_meshes(meshes, output_dir, prefix='')` (line 377): Save each group voxel mesh to its own VTU file. + - `def save_combined_surface(surfaces, output_dir, prefix='')` (line 403): Merge all group surfaces into a single VTP file. + - `def save_combined_mesh(meshes, output_dir, prefix='')` (line 438): Merge all group meshes into a single VTU file. + +## src/physiomotion4d/workflow_convert_heart_gated_ct_to_usd.py + +- **class WorkflowConvertHeartGatedCTToUSD** (line 28): Complete workflow for Heart-gated CT images to dynamic USD models. + - `def __init__(self, input_filenames, contrast_enhanced, output_directory, project_name, reference_image_filename=None, number_of_registration_iterations=1, registration_method='icon', log_level=logging.INFO)` (line 36): Initialize the Heart-gated CT to USD workflow. + - `def process(self)` (line 129): Execute the complete workflow from 4D CT to dynamic USD models. + +## src/physiomotion4d/workflow_convert_vtk_to_usd.py + +- **class WorkflowConvertVTKToUSD** (line 29): Workflow to convert one or more VTK files to USD with configurable + - `def __init__(self, vtk_files, output_usd, *, separate_by_connectivity=True, separate_by_cell_type=False, mesh_name='Mesh', times_per_second=60.0, up_axis='Y', triangulate=True, extract_surface=True, time_series_pattern='\\.t(\\d+)\\.(vtk|vtp|vtu)$', appearance='solid', solid_color=(0.8, 0.8, 0.8), anatomy_type='heart', colormap_primvar=None, colormap_name='viridis', colormap_intensity_range=None, log_level=logging.INFO)` (line 35): Initialize the VTK-to-USD workflow. + - `def discover_time_series(self, paths, pattern='\\.t(\\d+)\\.(vtk|vtp|vtu)$')` (line 105): Discover and sort time-series VTK files by extracted time index. + - `def run(self)` (line 141): Run the full workflow: convert VTK to USD, then apply the chosen appearance. + +## src/physiomotion4d/workflow_create_statistical_model.py + +- **class WorkflowCreateStatisticalModel** (line 35): Create a PCA statistical shape model from a sample of meshes aligned to a reference. + - `def __init__(self, sample_meshes, reference_mesh, pca_number_of_components=15, reference_spatial_resolution=1.0, reference_buffer_factor=0.25, solve_for_surface_pca=True, log_level=logging.INFO)` (line 56): Initialize the create-statistical-model workflow. + - `def set_pca_number_of_components(self, n)` (line 102): Set number of PCA components to retain. + - `def run_workflow(self)` (line 310): Run the full pipeline and return a dictionary of results (no file I/O). + +## src/physiomotion4d/workflow_fit_statistical_model_to_patient.py + +- **class WorkflowFitStatisticalModelToPatient** (line 44): Register anatomical models using multi-stage ICP, mask-based, and image-based + - `def __init__(self, template_model, patient_models=None, patient_image=None, segmentation_method='simpleware_heart', log_level=logging.INFO)` (line 123): Initialize the model-to-image-and-model registration pipeline. + - `def set_mask_dilation_mm(self, mask_dilation_mm)` (line 330): Set mask dilation amount for auto-generated masks. + - `def set_roi_dilation_mm(self, roi_dilation_mm)` (line 339): Set ROI mask dilation amount. + - `def set_use_pca_registration(self, use_pca_registration, pca_model=None, pca_number_of_modes=0, pca_uses_surface=True)` (line 348): Set whether to use PCA-based registration and provide the PCA model. + - `def set_use_mask_to_mask_registration(self, use_mask_to_mask_registration)` (line 383): Set whether to use mask-to-mask registration. + - `def set_use_mask_to_image_registration(self, use_mask_to_image_registration, template_labelmap=None, template_labelmap_organ_mesh_ids=None, template_labelmap_organ_extra_ids=None, template_labelmap_background_ids=None)` (line 394): Set whether to use mask-to-image registration. + - `def register_model_to_model_icp(self)` (line 448): Perform ICP alignment of template model to patient model. + - `def register_model_to_model_pca(self)` (line 508): Perform PCA-based registration after ICP alignment. + - `def register_mask_to_mask(self, use_icon_refinement=False)` (line 632): Perform mask-based deformable registration of model to patient model. + - `def register_labelmap_to_image(self, use_icon_refinement=False)` (line 700): Perform labelmap-to-image refinement. + - `def transform_model(self, base_model=None)` (line 819): Apply registration transforms to the model. + - `def run_workflow(self, use_icon_registration_refinement=False)` (line 894): Execute the complete multi-stage registration workflow. + +## src/physiomotion4d/workflow_reconstruct_highres_4d_ct.py + +- **class WorkflowReconstructHighres4DCT** (line 35): Reconstruct high-resolution 4D CT from time series and reference image. + - `def __init__(self, time_series_images, fixed_image, reference_frame=0, register_reference=False, registration_method='ants_icon', log_level=logging.INFO)` (line 92): Initialize the high-resolution 4D CT reconstruction workflow. + - `def set_number_of_iterations_ants(self, number_of_iterations_ants)` (line 174): Set the number of iterations for ANTs registration. + - `def set_number_of_iterations_icon(self, number_of_iterations_icon)` (line 185): Set the number of iterations for ICON registration. + - `def set_prior_weight(self, prior_weight)` (line 193): Set the weight for temporal smoothing with prior transforms. + - `def set_modality(self, modality)` (line 209): Set the imaging modality for registration optimization. + - `def set_mask_dilation(self, mask_dilation_mm)` (line 217): Set the dilation of the fixed and moving image masks. + - `def set_fixed_mask(self, fixed_mask)` (line 225): Set a binary mask for the fixed image region of interest. + - `def set_moving_masks(self, moving_masks)` (line 233): Set binary masks for the moving images. + - `def register_time_series(self)` (line 255): Register time series images to the fixed image. + - `def reconstruct_time_series(self, upsample_to_fixed_resolution=False)` (line 317): Reconstruct high-resolution time series using inverse transforms. + - `def run_workflow(self, upsample_to_fixed_resolution=False)` (line 369): Execute the complete high-resolution 4D CT reconstruction workflow. + +## tests/conftest.py + +- `def pytest_addoption(parser)` (line 36): Add custom command-line options for pytest. +- `def pytest_configure(config)` (line 52): Configure pytest with custom markers and settings. +- `def pytest_collection_modifyitems(config, items)` (line 75): Automatically skip experiment tests unless --run-experiments is passed. +- `def pytest_runtest_logreport(report)` (line 95): Collect test timing information after each test completes. +- `def pytest_terminal_summary(terminalreporter, exitstatus, config)` (line 119): Print comprehensive test timing report after all tests complete. +- `def test_directories()` (line 253): Set up test directories for data and results. +- `def download_truncal_valve_data(test_directories)` (line 268): Download TruncalValve 4D CT data. +- `def converted_3d_images(download_truncal_valve_data, test_directories)` (line 310): Convert 4D NRRD to 3D time series and return slice files. +- `def test_images(converted_3d_images)` (line 338): Load time points from the converted 3D data for testing. +- `def segmenter_total_segmentator()` (line 379): Create a SegmentChestTotalSegmentator instance. +- `def segmenter_vista_3d()` (line 385): Create a SegmentChestVista3D instance. +- `def segmenter_simpleware()` (line 391): Create a SegmentHeartSimpleware instance. +- `def heart_simpleware_image_path()` (line 397): Path to cardiac CT image used by experiments/Heart-Simpleware_Segmentation notebook. +- `def heart_simpleware_image(heart_simpleware_image_path)` (line 416): Load cardiac CT image for SegmentHeartSimpleware tests (same as notebook). +- `def segmentation_results(segmenter_total_segmentator, test_images, test_directories)` (line 422): Get or create segmentation results using TotalSegmentator. +- `def contour_tools()` (line 483): Create a ContourTools instance. +- `def registrar_ants()` (line 494): Create a RegisterImagesANTs instance. +- `def registrar_greedy()` (line 500): Create a RegisterImagesGreedy instance. +- `def registrar_icon()` (line 506): Create a RegisterImagesICON instance. +- `def ants_registration_results(registrar_ants, test_images, test_directories)` (line 512): Perform ANTs registration and return results. +- `def transform_tools()` (line 565): Create a TransformTools instance. + +## tests/test_contour_tools.py + +- **class TestContourTools** (line 17): Test suite for ContourTools functionality. + - `def test_contour_tools_initialization(self, contour_tools)` (line 20): Test that ContourTools initializes correctly. + - `def test_extract_contours_from_heart_mask(self, contour_tools, segmentation_results, test_directories)` (line 25): Test extracting contours from heart mask. + - `def test_extract_contours_from_lung_mask(self, contour_tools, segmentation_results, test_directories)` (line 55): Test extracting contours from lung mask. + - `def test_extract_contours_multiple_anatomy(self, contour_tools, segmentation_results, test_directories)` (line 81): Test extracting contours from multiple anatomical structures. + - `def test_create_mask_from_mesh(self, contour_tools, segmentation_results, test_images, test_directories)` (line 116): Test creating a mask from extracted mesh. + - `def test_merge_meshes(self, contour_tools, segmentation_results, test_directories)` (line 153): Test merging multiple meshes. + - `def test_transform_contours_identity(self, contour_tools, segmentation_results, test_directories)` (line 198): Test transforming contours with identity transform. + - `def test_transform_contours_with_deformation(self, contour_tools, segmentation_results, test_directories)` (line 241): Test transforming contours with deformation magnitude calculation. + - `def test_contours_from_both_time_points(self, contour_tools, segmentation_results, test_directories)` (line 287): Test extracting contours from both time points. + +## tests/test_convert_nrrd_4d_to_3d.py + +- **class TestConvertNRRD4DTo3D** (line 17): Test suite for converting 4D NRRD to 3D time series. + - `def test_convert_4d_to_3d(self, download_truncal_valve_data, test_directories)` (line 20): Test conversion of 4D NRRD to 3D time series (replicates notebook cell 3). + - `def test_slice_files_created(self, download_truncal_valve_data, test_directories)` (line 50): Test that all expected slice files are present after conversion. + - `def test_fixed_image_output(self, download_truncal_valve_data, test_directories)` (line 66): Test that fixed/reference image is copied to output directory. + - `def test_load_nrrd_4d(self, download_truncal_valve_data)` (line 81): Test loading 4D NRRD file. + - `def test_save_3d_images(self, download_truncal_valve_data, test_directories)` (line 94): Test saving 3D images from 4D NRRD. + +## tests/test_convert_vtk_to_usd.py + +- **class TestConvertVTKToUSD** (line 19): Test suite for VTK to USD PolyMesh conversion. + - `def contour_meshes(self, contour_tools, segmentation_results, test_directories)` (line 23): Extract or load contour meshes for USD conversion testing. + - `def test_converter_initialization(self)` (line 56): Test that ConvertVTKToUSD initializes correctly. + - `def test_supports_mesh_type(self, contour_meshes)` (line 67): Test that converter correctly identifies supported mesh types. + - `def test_convert_single_time_point(self, contour_meshes, test_directories)` (line 80): Test converting a single time point to USD. + - `def test_convert_multiple_time_points(self, contour_meshes, test_directories)` (line 111): Test converting multiple time points to USD. + - `def test_convert_with_deformation(self, contour_tools, segmentation_results, test_directories)` (line 144): Test converting meshes with deformation magnitude. + - `def test_convert_with_colormap(self, contour_meshes, test_directories)` (line 182): Test converting meshes with colormap visualization. + - `def test_convert_unstructured_grid_to_surface(self, test_directories)` (line 219): Test converting UnstructuredGrid to surface mesh. + - `def test_usd_file_structure(self, contour_meshes, test_directories)` (line 265): Test the structure of generated USD file. + - `def test_time_varying_topology(self, contour_meshes, test_directories)` (line 295): Test handling of time-varying topology. + - `def test_batch_conversion(self, contour_tools, segmentation_results, test_directories)` (line 334): Test converting multiple anatomy structures in batch. + +## tests/test_download_heart_data.py + +- **class TestDownloadHeartData** (line 13): Test suite for downloading and converting Slicer-Heart-CT data. + - `def test_directories_created(self, test_directories)` (line 16): Test that directories are created successfully. + - `def test_data_downloaded(self, download_truncal_valve_data, test_directories)` (line 26): Test that the TruncalValve 4D CT data file is downloaded. + +## tests/test_experiments.py + +- `def get_notebooks_in_subdir(subdir_name)` (line 53): Get all Jupyter notebooks in a subdirectory, sorted alphanumerically. +- `def clear_notebook_outputs(notebook_path)` (line 71): Clear all cell outputs from a Jupyter notebook. +- `def execute_notebook(notebook_path, timeout=3600)` (line 113): Execute a Jupyter notebook using nbconvert. +- `def run_experiment_notebooks(subdir_name, timeout_per_notebook=3600)` (line 236): Run all notebooks in an experiment subdirectory in alphanumeric order. +- `def test_experiment_colormap_vtk_to_usd()` (line 358): Test Colormap-VTK_To_USD experiment notebooks. +- `def test_experiment_reconstruct_4dct()` (line 392): Test Reconstruct4DCT experiment notebooks. +- `def test_experiment_heart_vtk_series_to_usd()` (line 411): Test Heart-VTKSeries_To_USD experiment notebooks. +- `def test_experiment_heart_gated_ct_to_usd()` (line 432): Test Heart-GatedCT_To_USD experiment notebooks. +- `def test_experiment_convert_vtk_to_usd()` (line 458): Test Convert_VTK_To_USD experiment notebooks. +- `def test_experiment_create_statistical_model()` (line 478): Test Heart-Create_Statistical_Model experiment notebooks. +- `def test_experiment_heart_statistical_model_to_patient()` (line 505): Test Heart-Statistical_Model_To_Patient experiment notebooks. +- `def test_experiment_lung_gated_ct_to_usd()` (line 540): Test Lung-GatedCT_To_USD experiment notebooks. +- `def test_experiment_structure()` (line 585): Validate the structure of the experiments directory. +- `def test_list_notebooks_in_subdir(subdir_name)` (line 639): List all notebooks in each experiment subdirectory. + +## tests/test_image_tools.py + +- **class TestImageTools** (line 19): Test suite for ImageTools conversions. + - `def image_tools(self)` (line 23): Create ImageTools instance. + - `def test_itk_to_sitk_scalar_image(self, image_tools)` (line 27): Test conversion of scalar ITK image to SimpleITK. + - `def test_sitk_to_itk_scalar_image(self, image_tools)` (line 62): Test conversion of scalar SimpleITK image to ITK. + - `def test_roundtrip_scalar_image(self, image_tools)` (line 94): Test roundtrip conversion: ITK -> SimpleITK -> ITK. + - `def test_itk_to_sitk_vector_image(self, image_tools)` (line 130): Test conversion of vector ITK image to SimpleITK. + - `def test_sitk_to_itk_vector_image(self, image_tools)` (line 168): Test conversion of vector SimpleITK image to ITK. + - `def test_roundtrip_vector_image(self, image_tools)` (line 199): Test roundtrip conversion for vector images: ITK -> SimpleITK -> ITK. + - `def test_imwrite_imread_vd3(self, image_tools, ants_registration_results, test_images, test_directories)` (line 238): Test reading and writing double precision vector images. +- **class TestFlipImage** (line 331): Unit tests for ImageTools.flip_image (axis flips and direction reset). + - `def image_tools(self)` (line 335) + - `def test_flip_x_flips_along_last_array_axis(self, image_tools)` (line 338): flip_x flips the image along the x (last) array dimension. + - `def test_flip_y_flips_along_middle_array_axis(self, image_tools)` (line 351): flip_y flips the image along the y (middle) array dimension. + - `def test_flip_z_flips_along_first_array_axis(self, image_tools)` (line 365): flip_z flips the image along the z (first) array dimension. + - `def test_flip_xy_combines_flips(self, image_tools)` (line 377): flip_x and flip_y together flip both axes. + - `def test_no_flip_returns_same_image(self, image_tools)` (line 387): With no flip flags, image is returned unchanged. + - `def test_mask_flipped_in_lockstep_with_image(self, image_tools)` (line 398): When a mask is provided, it is flipped with the same axes as the image. + - `def test_flip_and_make_identity_sets_direction_to_identity(self, image_tools)` (line 419): flip_and_make_identity flips as needed and sets direction matrix to identity. + - `def test_flip_and_make_identity_with_mask_sets_both_directions_to_identity(self, image_tools)` (line 435): With mask and flip_and_make_identity, both image and mask get identity direction. + +## tests/test_register_images_ants.py + +- **class TestRegisterImagesANTs** (line 22): Test suite for ANTs-based image registration. + - `def test_registrar_initialization(self, registrar_ants)` (line 25): Test that RegisterImagesANTs initializes correctly. + - `def test_set_modality(self, registrar_ants)` (line 33): Test setting imaging modality. + - `def test_set_fixed_image(self, registrar_ants, test_images)` (line 43): Test setting fixed image. + - `def test_register_without_mask(self, registrar_ants, test_images, test_directories)` (line 54): Test basic registration without masks. + - `def test_register_with_mask(self, registrar_ants, test_images, test_directories)` (line 103): Test registration with binary masks. + - `def test_transform_application(self, registrar_ants, test_images, test_directories)` (line 191): Test applying registration transforms to images. + - `def test_preprocess_images(self, registrar_ants, test_images)` (line 240): Test image preprocessing. + - `def test_registration_with_initial_transform(self, registrar_ants, test_images, test_directories)` (line 256): Test registration with initial transform. + - `def test_multiple_registrations(self, registrar_ants, test_images)` (line 288): Test running multiple registrations in sequence. + - `def test_transform_types(self, registrar_ants, test_images)` (line 314): Test that transforms are correct ITK types. + - `def test_image_conversion_cycle_scalar(self, registrar_ants, test_images)` (line 340): Test round-trip conversion: ITK image -> ANTs -> ITK for scalar images. + - `def test_image_conversion_cycle_different_dtypes(self, registrar_ants, test_images)` (line 414): Test round-trip conversion with different data types. + - `def test_image_conversion_preserves_metadata(self, registrar_ants)` (line 444): Test that image conversion preserves all metadata. + - `def test_transform_conversion_cycle_affine(self, registrar_ants, test_images)` (line 489): Test round-trip conversion: ITK affine transform -> ANTs -> ITK. + - `def test_transform_conversion_cycle_displacement_field(self, registrar_ants, test_images)` (line 593): Test round-trip conversion: ITK displacement field -> ANTs -> ITK. + - `def test_transform_conversion_with_composite(self, registrar_ants, test_images)` (line 677): Test conversion of composite transforms. + +## tests/test_register_images_greedy.py + +- **class TestRegisterImagesGreedy** (line 18): Test suite for Greedy-based image registration. + - `def test_registrar_initialization(self, registrar_greedy)` (line 21): Test that RegisterImagesGreedy initializes correctly. + - `def test_set_modality(self, registrar_greedy)` (line 29): Test setting imaging modality. + - `def test_set_transform_type_and_metric(self, registrar_greedy)` (line 39): Test setting transform type and metric. + - `def test_set_fixed_image(self, registrar_greedy, test_images)` (line 64): Test setting fixed image. + - `def test_register_affine_without_mask(self, registrar_greedy, test_images, test_directories)` (line 73): Test affine registration without masks. + - `def test_register_affine_with_mask(self, registrar_greedy, test_images, test_directories)` (line 115): Test affine registration with binary masks. + - `def test_transform_application(self, registrar_greedy, test_images, test_directories)` (line 172): Test applying registration transform to moving image. + +## tests/test_register_images_icon.py + +- **class TestRegisterImagesICON** (line 19): Test suite for ICON-based image registration. + - `def test_registrar_initialization(self, registrar_icon)` (line 22): Test that RegisterImagesICON initializes correctly. + - `def test_set_modality(self, registrar_icon)` (line 35): Test setting imaging modality. + - `def test_set_number_of_iterations(self, registrar_icon)` (line 45): Test setting number of iterations. + - `def test_set_fixed_image(self, registrar_icon, test_images)` (line 57): Test setting fixed image. + - `def test_set_mass_preservation(self, registrar_icon)` (line 68): Test setting mass preservation flag. + - `def test_set_multi_modality(self, registrar_icon)` (line 80): Test setting multi-modality flag. + - `def test_register_without_mask(self, registrar_icon, test_images, test_directories)` (line 90): Test basic ICON registration without masks. + - `def test_register_with_mask(self, registrar_icon, test_images, test_directories)` (line 140): Test ICON registration with binary masks. + - `def test_transform_application(self, registrar_icon, test_images, test_directories)` (line 229): Test applying ICON registration transforms to images. + - `def test_inverse_consistency(self, registrar_icon, test_images)` (line 278): Test ICON's inverse consistency property. + - `def test_preprocess_images(self, registrar_icon, test_images)` (line 322): Test image preprocessing for ICON. + - `def test_registration_with_initial_transform(self, registrar_icon, test_images, test_directories)` (line 338): Test ICON registration with initial transform. + - `def test_transform_types(self, registrar_icon, test_images)` (line 374): Test that ICON transforms are correct ITK types. + - `def test_different_iteration_counts(self, registrar_icon, test_images)` (line 413): Test ICON with different iteration counts. + +## tests/test_register_time_series_images.py + +- **class TestRegisterTimeSeriesImages** (line 20): Test suite for time series image registration. + - `def test_registrar_initialization_ants(self)` (line 23): Test that RegisterTimeSeriesImages initializes correctly with ANTs. + - `def test_registrar_initialization_icon(self)` (line 37): Test that RegisterTimeSeriesImages initializes correctly with ICON. + - `def test_registrar_initialization_invalid_method(self)` (line 51): Test that invalid registration method raises error. + - `def test_set_modality(self)` (line 58): Test setting imaging modality. + - `def test_set_fixed_image(self, test_images)` (line 66): Test setting fixed image. + - `def test_set_number_of_iterations(self)` (line 77): Test setting number of iterations. + - `def test_register_time_series_basic(self, test_images, test_directories)` (line 97): Test basic time series registration without prior transform. + - `def test_register_time_series_with_prior(self, test_images, test_directories)` (line 179): Test time series registration with prior transform usage. + - `def test_register_time_series_identity_start(self, test_images)` (line 241): Test time series registration with identity for starting image. + - `def test_register_time_series_different_starting_indices(self, test_images)` (line 267): Test time series registration with different starting indices. + - `def test_register_time_series_error_no_fixed_image(self)` (line 295): Test that error is raised if fixed image not set. + - `def test_register_time_series_error_invalid_starting_index(self, test_images)` (line 306): Test that error is raised for invalid starting index. + - `def test_register_time_series_error_invalid_prior_portion(self, test_images)` (line 327): Test that error is raised for invalid prior portion value. + - `def test_transform_application_time_series(self, test_images, test_directories)` (line 350): Test applying transforms from time series registration. + - `def test_register_time_series_icon(self, test_images)` (line 404): Test time series registration with ICON method. + - `def test_register_time_series_with_mask(self, test_images, test_directories)` (line 429): Test time series registration with fixed image mask. + - `def test_bidirectional_registration(self, test_images)` (line 472): Test that bidirectional registration works correctly. + +## tests/test_segment_chest_total_segmentator.py + +- **class TestSegmentChestTotalSegmentator** (line 16): Test suite for TotalSegmentator chest CT segmentation. + - `def test_segmenter_initialization(self, segmenter_total_segmentator)` (line 19): Test that SegmentChestTotalSegmentator initializes correctly. + - `def test_segment_single_image(self, segmenter_total_segmentator, test_images, test_directories)` (line 54): Test segmentation on a single time point. + - `def test_segment_multiple_images(self, segmenter_total_segmentator, test_images, test_directories)` (line 109): Test segmentation on two time points. + - `def test_anatomy_group_masks(self, segmenter_total_segmentator, test_images)` (line 137): Test that anatomy group masks are created correctly. + - `def test_contrast_detection(self, segmenter_total_segmentator, test_images)` (line 177): Test contrast detection functionality. + - `def test_preprocessing(self, segmenter_total_segmentator, test_images)` (line 204): Test preprocessing functionality. + - `def test_postprocessing(self, segmenter_total_segmentator, test_images)` (line 224): Test postprocessing functionality. + +## tests/test_segment_chest_vista_3d.py + +- **class TestSegmentChestVista3D** (line 16): Test suite for VISTA-3D chest CT segmentation. + - `def test_segmenter_initialization(self, segmenter_vista_3d)` (line 19): Test that SegmentChestVista3D initializes correctly. + - `def test_segment_single_image(self, segmenter_vista_3d, test_images, test_directories)` (line 51): Test automatic segmentation on a single time point. + - `def test_segment_multiple_images(self, segmenter_vista_3d, test_images, test_directories)` (line 107): Test automatic segmentation on two time points. + - `def test_anatomy_group_masks(self, segmenter_vista_3d, test_images)` (line 138): Test that anatomy group masks are created correctly. + - `def test_label_prompt_segmentation(self, segmenter_vista_3d, test_images, test_directories)` (line 177): Test segmentation with specific label prompts. + - `def test_contrast_detection(self, segmenter_vista_3d, test_images)` (line 213): Test contrast detection functionality. + - `def test_preprocessing(self, segmenter_vista_3d, test_images)` (line 241): Test preprocessing functionality. + - `def test_postprocessing(self, segmenter_vista_3d, test_images)` (line 260): Test postprocessing functionality. + - `def test_set_and_reset_prompts(self, segmenter_vista_3d)` (line 288): Test setting and resetting label prompt mode. + +## tests/test_segment_heart_simpleware.py + +- **class TestSegmentHeartSimpleware** (line 28): Test suite for SegmentHeartSimpleware (Simpleware Medical ASCardio). + - `def test_segmenter_initialization(self, segmenter_simpleware)` (line 31): Test that SegmentHeartSimpleware initializes correctly. + - `def test_set_simpleware_executable_path(self, segmenter_simpleware)` (line 55): Test setting custom Simpleware executable path. + - `def test_segment_single_image(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path, test_directories)` (line 66): Test segmentation on the same cardiac CT as the notebook (RVOT28-Dias.nii.gz). + - `def test_anatomy_group_masks(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path)` (line 125): Test that anatomy group masks are created (heart, vessels, etc.). + - `def test_contrast_detection(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path)` (line 160): Test contrast mask is returned (base class behavior). + - `def test_postprocessing(self, segmenter_simpleware, heart_simpleware_image, heart_simpleware_image_path)` (line 174): Test that output labelmap matches input size and spacing. + +## tests/test_transform_tools.py + +- **class TestTransformTools** (line 20): Test suite for TransformTools functionality. + - `def test_contour(self, test_images)` (line 24): Create a simple test contour mesh. + - `def test_transform_tools_initialization(self, transform_tools)` (line 30): Test that TransformTools initializes correctly. + - `def test_transform_image_linear(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 35): Test transforming image with linear interpolation. + - `def test_transform_image_nearest(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 71): Test transforming image with nearest neighbor interpolation. + - `def test_transform_image_sinc(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 101): Test transforming image with sinc interpolation. + - `def test_transform_image_invalid_method(self, transform_tools, ants_registration_results, test_images)` (line 131): Test that invalid interpolation method raises error. + - `def test_transform_pvcontour_without_deformation(self, transform_tools, test_contour, ants_registration_results)` (line 151): Test transforming PyVista contour without deformation magnitude. + - `def test_transform_pvcontour_with_deformation(self, transform_tools, test_contour, ants_registration_results, test_directories)` (line 183): Test transforming PyVista contour with deformation magnitude. + - `def test_convert_transform_to_displacement_field(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 222): Test converting transform to deformation field image. + - `def test_convert_vtk_matrix_to_itk_transform(self, transform_tools)` (line 259): Test converting VTK matrix to ITK transform. + - `def test_compute_jacobian_determinant_from_field(self, transform_tools, ants_registration_results, test_images, test_directories)` (line 289): Test computing Jacobian determinant from deformation field. + - `def test_detect_folding_in_field(self, transform_tools, ants_registration_results, test_images)` (line 336): Test detecting spatial folding in deformation field. + - `def test_interpolate_transforms(self, transform_tools, ants_registration_results, test_images)` (line 363): Test temporal interpolation between transforms. + - `def test_combine_displacement_field_transforms(self, transform_tools, ants_registration_results, test_images)` (line 397): Test composing two transforms with various weights. + - `def test_smooth_transform(self, transform_tools, ants_registration_results, test_images)` (line 508): Test smoothing a transform. + - `def test_combine_transforms_with_masks(self, transform_tools, ants_registration_results, test_images)` (line 531): Test combining transforms with spatial masks. + - `def test_multiple_transform_applications(self, transform_tools, ants_registration_results, test_images)` (line 575): Test applying multiple transforms in sequence. + - `def test_identity_transform(self, transform_tools, test_images)` (line 600): Test that identity transform doesn't change the image. + +## tests/test_usd_merge.py + +- `def analyze_usd_file(filepath)` (line 16): Analyze a USD file for materials and time samples. +- **class TestUSDMerge** (line 72): Test suite for USD file merging. + - `def test_data_files(self)` (line 76): Locate test USD files with materials and time-varying data. + - `def output_dir(self, tmp_path_factory)` (line 91): Create temporary output directory for test results. + - `def input_stats(self, test_data_files)` (line 97): Analyze input USD files. + - `def test_merge_usd_files_copy_method(self, test_data_files, input_stats, output_dir)` (line 103): Test merge_usd_files() manual copy method. + - `def test_merge_usd_files_flattened_method(self, test_data_files, input_stats, output_dir)` (line 161): Test merge_usd_files_flattened() composition method. + - `def test_both_methods_produce_equivalent_results(self, test_data_files, output_dir)` (line 219): Verify both merge methods produce equivalent results. + +## tests/test_usd_time_preservation.py + +- `def get_time_metadata(filepath)` (line 16): Extract time metadata from a USD file. +- `def get_mesh_time_samples(filepath, mesh_name='inferior_vena_cava')` (line 40): Get time sample data for a specific mesh in a USD file. +- **class TestUSDTimePreservation** (line 84): Test suite for USD time-varying data preservation. + - `def test_data_files(self)` (line 88): Locate test USD files with time-varying data. + - `def output_dir(self, tmp_path_factory)` (line 103): Create temporary output directory for test results. + - `def source_metadata(self, test_data_files)` (line 109): Get time metadata from source file. + - `def source_time_samples(self, test_data_files)` (line 114): Get time sample data from source file. + - `def test_merge_copy_preserves_time_metadata(self, test_data_files, source_metadata, output_dir)` (line 118): Test that merge_usd_files() preserves time metadata. + - `def test_merge_flattened_preserves_time_metadata(self, test_data_files, source_metadata, output_dir)` (line 147): Test that merge_usd_files_flattened() preserves time metadata. + - `def test_merge_copy_preserves_time_samples(self, test_data_files, source_time_samples, output_dir)` (line 176): Test that merge_usd_files() preserves actual time sample data. + - `def test_merge_flattened_preserves_time_samples(self, test_data_files, source_time_samples, output_dir)` (line 214): Test that merge_usd_files_flattened() preserves actual time sample data. + - `def test_animation_range_matches_actual_motion(self, test_data_files, source_time_samples, output_dir)` (line 252): Test that the full animation range is accessible. + +## tests/test_vtk_to_usd_library.py + +- `def get_data_dir()` (line 35): Get the data directory path. +- `def check_kcl_heart_data()` (line 42): Check if KCL Heart Model data is available. +- `def check_valve4d_data()` (line 49): Check if CHOP Valve4D data is available. +- `def get_or_create_average_surface(test_directories)` (line 56): Get or create average_surface.vtp from average_mesh.vtk. +- `def kcl_average_surface(test_directories)` (line 100): Fixture providing the KCL average heart surface. +- **class TestGenericArray** (line 116): Test GenericArray data structure validation and reshaping. + - `def test_scalar_1d_array(self)` (line 119): Test that 1D scalar arrays (num_components=1) are kept as-is. + - `def test_flat_multicomponent_array_reshape(self)` (line 132): Test that flat 1D arrays with num_components>1 are reshaped to 2D. + - `def test_2d_array_valid(self)` (line 148): Test that 2D arrays with correct shape are accepted. + - `def test_flat_array_not_divisible_raises_error(self)` (line 161): Test that flat arrays with length not divisible by num_components raise error. + - `def test_2d_array_wrong_shape_raises_error(self)` (line 172): Test that 2D arrays with wrong shape raise error. + - `def test_3d_array_raises_error(self)` (line 183): Test that 3D arrays are rejected. + - `def test_flat_array_large_components(self)` (line 194): Test reshaping with large num_components (e.g., 9 for 3x3 tensors). +- **class TestVTKReader** (line 210): Test VTK file reading capabilities. + - `def test_read_vtp_file(self, kcl_average_surface)` (line 213): Test reading VTP (PolyData) files. + - `def test_read_legacy_vtk_file(self)` (line 234): Test reading legacy VTK files. + - `def test_generic_arrays_preserved(self, kcl_average_surface)` (line 261): Test that generic data arrays are preserved during reading. +- **class TestVTKToUSDConversion** (line 285): Test VTK to USD conversion capabilities. + - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 288): Test converting a single VTK file to USD. + - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 328): Test conversion with custom material. + - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 373): Test conversion with custom settings. + - `def test_primvar_preservation(self, test_directories, kcl_average_surface)` (line 406): Test that VTK data arrays are preserved as USD primvars. +- **class TestTimeSeriesConversion** (line 444): Test time-series conversion capabilities. + - `def test_time_series_conversion(self, test_directories, kcl_average_surface)` (line 447): Test converting multiple VTK files as time series. +- **class TestIntegration** (line 495): Integration tests combining multiple features. + - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 498): Test complete conversion workflow with all features. + +## utils/claude_github_reviews.py + +- `def get_repo_root()` (line 51) +- `def get_repo_slug(repo_root)` (line 65): Derive owner/repo from the git remote origin URL. +- `def parse_github_datetime(iso_str)` (line 86): Parse GitHub API timestamps (may end with Z). +- `def get_remote_reflog_cutoff(repo_root, remote, head_ref)` (line 102): Latest reflog time for refs/remotes// (when the ref last +- `def filter_since_cutoff(inline_comments, reviews, cutoff)` (line 142): Keep inline comments with created_at > cutoff and reviews with +- `def fetch_pr_data(pr_number, repo)` (line 220) +- `def fetch_inline_comments(pr_number, repo)` (line 226) +- `def fetch_reviews(pr_number, repo)` (line 231) +- `def build_prompt(pr_number, pr_data, reviews, inline_comments, summary_filename)` (line 304) +- `def invoke_claude(prompt, repo_root)` (line 421): Invoke Claude Code non-interactively via stdin. +- `def parse_args()` (line 469) +- `def main()` (line 513) + +## utils/generate_api_map.py + +- `def first_docstring_line(node)` (line 47): Return the first non-empty line of a class or function docstring. +- `def format_arg(arg, default)` (line 65): Format one argument, appending ``=default`` when a default is present. +- `def format_signature(node)` (line 76): Reconstruct a readable signature string from an AST function node. +- `def is_public(name)` (line 104): Return True for public names; also allow ``__init__``. +- `def module_all_names(tree)` (line 109): Return the names listed in ``__all__`` if defined at module level, else None. +- **class MethodEntry** (line 130): A single public method within a class. + - `def __init__(self, signature, lineno, summary)` (line 135) +- **class ClassEntry** (line 141): A public class with its public methods. + - `def __init__(self, name, lineno, summary)` (line 146) +- **class FunctionEntry** (line 153): A public module-level function. + - `def __init__(self, signature, lineno, summary)` (line 158) +- **class ModuleEntry** (line 164): All public symbols extracted from one source file. + - `def __init__(self, rel_path)` (line 169) +- `def parse_module(path, root)` (line 180): Parse *path* and return a :class:`ModuleEntry`, or ``None`` on error / empty. +- `def find_python_files(root)` (line 234): Return sorted .py files under *root*, skipping non-source directories. +- `def render_markdown(modules)` (line 250): Render *modules* to a Markdown string. +- `def main()` (line 295) + +## utils/prepare_notebooks_for_commit.py + +- `def clear_cell_outputs(cell)` (line 22): Clear outputs and execution state for a single cell. +- `def strip_widget_state(nb)` (line 32): Remove Jupyter widget state from notebook metadata (ipywidgets, PyVista, etc.). +- `def clear_notebook(path)` (line 44): Clear all cell outputs and strip widget state in a notebook file in place. +- `def find_notebooks(root)` (line 98): Return all .ipynb files under root, excluding hidden and common ignore dirs. +- `def main()` (line 111) + +## utils/setup_feature_worktree.py + +- `def run(cmd, *, cwd=None, capture=False, description='')` (line 38): Run a command, raising on failure with a helpful message. +- `def require_tool(name)` (line 82): Ensure a tool is on PATH. Returns its resolved path. +- `def check_prerequisites()` (line 100): Check that git and py.exe are available on PATH. +- `def get_repo_root()` (line 119): Return the absolute path to the repository root. +- `def get_current_branch()` (line 140): Return the name of the currently checked-out branch. +- `def branch_exists(branch_name)` (line 162): Return True if a local branch with this name already exists. +- `def sanitize_name(raw)` (line 177): Convert a raw feature name into a safe branch name and folder name. +- `def create_worktree(worktree_path, branch_name, base_branch)` (line 219): Create a new git branch and worktree. +- `def create_venv(worktree_path, py_path)` (line 262): Create a virtual environment inside the worktree. +- `def install_uv(venv_dir)` (line 283): Install uv into the venv using pip. +- `def detect_dependency_mode(worktree_path)` (line 318): Auto-detect the best dependency installation mode for the project. +- `def install_dependencies(uv_exe, worktree_path, mode)` (line 339): Install project dependencies using uv. +- `def print_summary(branch_name, worktree_path, venv_dir)` (line 411): Print a formatted summary of the created worktree environment. +- `def parse_args()` (line 441): Parse and return command-line arguments. +- `def main()` (line 500): Main entry point for the worktree setup script. diff --git a/src/physiomotion4d/vtk_to_usd/CLAUDE.md b/src/physiomotion4d/vtk_to_usd/CLAUDE.md new file mode 100644 index 0000000..3c59e8f --- /dev/null +++ b/src/physiomotion4d/vtk_to_usd/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md — vtk_to_usd + +This subpackage is an **advanced internal library**. Understand this before touching it. + +## Preferred API + +**Do not add calls to `vtk_to_usd` from outside this subpackage.** + +The correct entry point for all VTK→USD conversion in PhysioMotion4D is: + +```python +from physiomotion4d.convert_vtk_to_usd import ConvertVTKToUSD +``` + +`ConvertVTKToUSD` operates on in-memory PyVista objects, handles colormap overlays, +multi-label anatomy, and animated time series, and is the only API that external users +or other PhysioMotion4D modules should call. + +## Role of this subpackage + +`vtk_to_usd/` is called internally by `ConvertVTKToUSD`. It provides: +- File-based VTK→USD conversion (reads `.vtk`, `.vtp`, `.vtu` from disk) +- Low-level data structures: `MeshData`, `ConversionSettings`, `MaterialData` +- USD primitive writing: normals, primvars, time samples, materials + +Other PhysioMotion4D modules (workflow, segmentation, registration) should never +import directly from `physiomotion4d.vtk_to_usd`; they must go through +`ConvertVTKToUSD`. External library users may use the file-based API. + +## When to edit this subpackage + +Only edit code here when: +1. `ConvertVTKToUSD` cannot expose the needed behavior through its own API, **and** +2. The change is to the file-based conversion layer itself (readers, USD writers, data structures). + +Always check whether the fix belongs in `convert_vtk_to_usd.py` first. + +## Module responsibilities + +| File | Responsibility | +|-----------------------|-----------------------------------------------------| +| `data_structures.py` | Data containers: `MeshData`, `MaterialData`, etc. | +| `vtk_reader.py` | Read `.vtk`, `.vtp`, `.vtu` files into `MeshData` | +| `usd_utils.py` | Coordinate conversion (RAS→Y-up), primvar helpers | +| `material_manager.py` | `UsdPreviewSurface` creation and binding | +| `usd_mesh_converter.py` | Write `MeshData` to a USD prim | +| `converter.py` | `VTKToUSDConverter` — high-level file-based API | + +## Coordinate system + +RAS→Y-up conversion: `USD(x, y, z) = RAS(x, z, -y)` + +This conversion happens inside `usd_utils.ras_to_usd()` / `ras_points_to_usd()`. +It must not be applied more than once. If you add a code path that produces USD +geometry, verify the transform is applied exactly once. + +## What not to do + +- Do not expose new public symbols in `__init__.py` without a clear reason. +- Do not call `vtk_to_usd` internals from `workflow_*.py` or any other top-level module. +- Do not duplicate coordinate conversion logic outside `usd_utils.py`. diff --git a/utils/claude_github_reviews.py b/utils/claude_github_reviews.py new file mode 100644 index 0000000..114676b --- /dev/null +++ b/utils/claude_github_reviews.py @@ -0,0 +1,608 @@ +#!/usr/bin/env python +""" +claude_github_reviews.py — Screen GitHub PR review comments with Claude. + +Workflow: + 1. Fetch all inline review comments and PR-level review bodies via gh CLI. + 2. Build a structured prompt that includes file paths, line numbers, diff + hunks, and suggestion blocks for every comment. + 3. Invoke Claude Code non-interactively; Claude reads CLAUDE.md and AGENTS.md, + reads the referenced source files for context, and for each comment decides + APPLY / REVISE / REJECT with explicit reasoning against project conventions. + 4. Accepted edits are applied as pending working-tree changes (not committed). + 5. A Markdown summary tagged by PR number is written to the repo root. + +Usage: + py utils/claude_github_reviews.py --pr 42 + py utils/claude_github_reviews.py --pr 42 --repo owner/repo + py utils/claude_github_reviews.py --pr 42 --dry-run + py utils/claude_github_reviews.py --pr 42 --since-last-push --dry-run + + With --since-last-push, only inline comments and PR-level reviews created after + the latest reflog time for refs/remotes// are included. + That time is when this clone last saw the remote ref move (push or fetch), not + necessarily the exact server push timestamp. + +Requirements: + - gh CLI (GitHub CLI) — not a Python package; install separately: + Windows: winget install GitHub.cli + Then authenticate: gh auth login + - Claude Code CLI — https://claude.ai/code + Windows: winget install Anthropic.ClaudeCode +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import textwrap +from datetime import datetime +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Git / repo helpers +# --------------------------------------------------------------------------- + + +def get_repo_root() -> Path: + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + text=True, + capture_output=True, + ) + return Path(out.stdout.strip()) + except subprocess.CalledProcessError: + print("[ERROR] Not inside a Git repository.") + sys.exit(1) + + +def get_repo_slug(repo_root: Path) -> str: + """Derive owner/repo from the git remote origin URL.""" + try: + out = subprocess.run( + ["git", "remote", "get-url", "origin"], + check=True, + text=True, + capture_output=True, + cwd=repo_root, + ) + remote = out.stdout.strip() + except subprocess.CalledProcessError: + print("[ERROR] Could not read git remote origin.") + sys.exit(1) + m = re.search(r"[:/]([^/:]+/[^/:]+?)(?:\.git)?$", remote) + if not m: + print(f"[ERROR] Cannot parse owner/repo from remote: {remote}") + sys.exit(1) + return m.group(1) + + +def parse_github_datetime(iso_str: str) -> datetime: + """Parse GitHub API timestamps (may end with Z).""" + s = iso_str.strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + return datetime.fromisoformat(s) + + +def _parse_reflog_timestamp(line: str) -> datetime: + """Parse the @{...} timestamp from one `git reflog show -1` line.""" + m = re.search(r"@\{([^}]+)\}", line) + if not m: + raise ValueError(f"no reflog timestamp in line: {line!r}") + return datetime.fromisoformat(m.group(1).strip()) + + +def get_remote_reflog_cutoff(repo_root: Path, remote: str, head_ref: str) -> datetime: + """ + Latest reflog time for refs/remotes// (when the ref last + moved in this clone). + """ + ref = f"refs/remotes/{remote}/{head_ref}" + cmd = ["git", "reflog", "show", "-1", "--date=iso-strict", ref] + try: + out = subprocess.run( + cmd, + check=True, + text=True, + capture_output=True, + cwd=repo_root, + encoding="utf-8", + ) + except FileNotFoundError: + print('[ERROR] "git" not found in PATH.') + sys.exit(1) + except subprocess.CalledProcessError as exc: + err = (exc.stderr or exc.stdout or "").strip() + print(f"[ERROR] Could not read reflog for {ref}.") + if err: + print(f" {err}") + print(f" Hint: git fetch {remote} {head_ref}") + sys.exit(1) + + line = out.stdout.strip().splitlines() + if not line or not line[0].strip(): + print(f"[ERROR] Empty reflog for {ref}.") + print(f" Hint: git fetch {remote} {head_ref}") + sys.exit(1) + + try: + return _parse_reflog_timestamp(line[0]) + except ValueError as exc: + print(f"[ERROR] Could not parse reflog timestamp: {exc}") + sys.exit(1) + + +def filter_since_cutoff( + inline_comments: list[dict], + reviews: list[dict], + cutoff: datetime, +) -> tuple[list[dict], list[dict]]: + """ + Keep inline comments with created_at > cutoff and reviews with + submitted_at > cutoff (submitted_at required). + """ + filtered_inline: list[dict] = [] + for c in inline_comments: + created = c.get("created_at") + if not created: + continue + if parse_github_datetime(created) > cutoff: + filtered_inline.append(c) + + filtered_reviews: list[dict] = [] + for r in reviews: + submitted = r.get("submitted_at") + if not submitted: + continue + if parse_github_datetime(submitted) > cutoff: + filtered_reviews.append(r) + + return filtered_inline, filtered_reviews + + +# --------------------------------------------------------------------------- +# GitHub API helpers (via gh CLI) +# --------------------------------------------------------------------------- + + +def _gh_api(endpoint: str, *, paginate: bool = False) -> list | dict: + """Call `gh api` and return parsed JSON. Merges paginated arrays.""" + cmd = ["gh", "api"] + if paginate: + cmd.append("--paginate") + cmd.append(endpoint) + try: + out = subprocess.run( + cmd, check=True, text=True, capture_output=True, encoding="utf-8" + ) + except FileNotFoundError: + print('[ERROR] "gh" CLI not found. Install from https://cli.github.com') + sys.exit(1) + except subprocess.CalledProcessError as exc: + msg = exc.stderr.strip() if exc.stderr else "" + print(f"[ERROR] gh api {endpoint} failed.") + if msg: + print(f" {msg}") + sys.exit(exc.returncode) + + raw = out.stdout.strip() + if not raw: + return [] + + # gh --paginate emits concatenated JSON arrays; normalize to one list. + if paginate: + try: + merged: list = [] + decoder = json.JSONDecoder() + pos = 0 + while pos < len(raw): + obj, pos = decoder.raw_decode(raw, pos) + if isinstance(obj, list): + merged.extend(obj) + else: + merged.append(obj) + while pos < len(raw) and raw[pos] in " \t\n\r": + pos += 1 + return merged + except json.JSONDecodeError: + pass # Fall through to standard parse + + return json.loads(raw) + + +def fetch_pr_data(pr_number: int, repo: str) -> dict: + result = _gh_api(f"repos/{repo}/pulls/{pr_number}") + assert isinstance(result, dict) + return result + + +def fetch_inline_comments(pr_number: int, repo: str) -> list[dict]: + result = _gh_api(f"repos/{repo}/pulls/{pr_number}/comments", paginate=True) + return result if isinstance(result, list) else [] + + +def fetch_reviews(pr_number: int, repo: str) -> list[dict]: + result = _gh_api(f"repos/{repo}/pulls/{pr_number}/reviews", paginate=True) + return result if isinstance(result, list) else [] + + +# --------------------------------------------------------------------------- +# Prompt construction +# --------------------------------------------------------------------------- + + +def _extract_suggestion(body: str) -> tuple[str, str]: + """ + Split a GitHub review comment body into (suggestion_code, note_text). + + GitHub / CodeRabbit suggestion blocks use the fenced form: + ```suggestion + + ``` + Returns ('', body) when no suggestion block is present. + """ + pattern = re.compile(r"```suggestion\r?\n(.*?)```", re.DOTALL) + m = pattern.search(body) + if not m: + return "", body + suggestion = m.group(1).rstrip("\n") + note = pattern.sub("", body).strip() + return suggestion, note + + +def _format_review_bodies(reviews: list[dict]) -> str: + relevant = [ + r + for r in reviews + if r.get("body", "").strip() and r.get("state") not in ("PENDING", "") + ] + if not relevant: + return "" + parts = ["## PR-level review comments\n"] + for i, r in enumerate(relevant, 1): + reviewer = r["user"]["login"] + state = r.get("state", "") + parts.append(f"### Review {i} — {reviewer} ({state})") + parts.append(r["body"].strip()) + parts.append("") + return "\n".join(parts) + + +def _format_inline_comments(comments: list[dict]) -> str: + if not comments: + return "" + parts = ["## Inline review comments\n"] + for i, c in enumerate(comments, 1): + reviewer = c["user"]["login"] + path = c["path"] + line = c.get("line") or c.get("original_line", "?") + diff_hunk = c.get("diff_hunk", "") + suggestion, note = _extract_suggestion(c["body"]) + + parts.append(f"### Inline comment {i}") + parts.append(f"- **Reviewer:** `{reviewer}`") + parts.append(f"- **File:** `{path}`") + parts.append(f"- **Line:** {line}") + if diff_hunk: + parts.append(f"\nDiff context:\n```diff\n{diff_hunk}\n```") + if suggestion: + parts.append("\nSuggested replacement (replaces the highlighted lines):") + parts.append(f"```\n{suggestion}\n```") + if note: + parts.append(f"\nReviewer note: {note}") + parts.append("") + return "\n".join(parts) + + +def build_prompt( + pr_number: int, + pr_data: dict, + reviews: list[dict], + inline_comments: list[dict], + summary_filename: str, +) -> str: + title = pr_data.get("title", f"PR #{pr_number}") + branch = pr_data.get("head", {}).get("ref", "unknown") + base = pr_data.get("base", {}).get("ref", "unknown") + + review_bodies = [ + r + for r in reviews + if r.get("body", "").strip() and r.get("state") not in ("PENDING", "") + ] + total = len(inline_comments) + len(review_bodies) + + comments_block = "\n".join( + filter( + None, + [ + _format_review_bodies(reviews), + _format_inline_comments(inline_comments), + ], + ) + ) + + return textwrap.dedent(f"""\ + You are screening GitHub PR #{pr_number}: "{title}" + Branch: `{branch}` -> `{base}` + Total comments to assess: {total} + + ## Step 1 — Read project standards + + Before assessing any comment, read these files: + - `CLAUDE.md` — coding standards, architecture, working process + - `AGENTS.md` — role expectations for this codebase + - If any comment touches `vtk_to_usd/`: + `src/physiomotion4d/vtk_to_usd/CLAUDE.md` + + ## Step 2 — Assess each comment + + For every comment below, in order: + + 1. Read the referenced source file (`path`, near `line`) to understand + the full context — do not rely solely on the diff hunk. + 2. Decide: + - **APPLY** — suggestion is correct and consistent with CLAUDE.md. + Apply it as-is using the Edit tool. + - **REVISE** — directionally right but conflicts with repo conventions. + Apply your corrected version using the Edit tool. + - **REJECT** — wrong, unnecessary, or conflicts with explicit project rules. + Do not edit the file. State the specific rule or reason. + 3. For APPLY / REVISE: use the Edit tool to make the change. + Do NOT run git add, git commit, or any git staging commands. + Leave all edits as pending working-tree modifications only. + + Rejection triggers (from CLAUDE.md — treat these as hard rules): + - Introduces `X | None` instead of `Optional[X]` (ruff UP007 is suppressed) + - Adds backward-compat shims, re-exports, or removed-symbol stubs + - Adds error handling for internal states that cannot happen + - In classes that inherit from `PhysioMotion4DBase`, uses `print()` instead + of `self.log_info()` / `self.log_debug()` + - New runtime workflow / segmentation / registration class does not inherit + from `PhysioMotion4DBase`; helper/data/container classes need not + - Adds features or abstractions beyond what was requested + - Calls `vtk_to_usd` internals from outside `convert_vtk_to_usd.py` + - Applies coordinate conversion (RAS->Y-up) more than once + - Exceeds 88-character line length + + ## Step 3 — Write summary + + After processing all {total} comment(s), write `{summary_filename}` + to the repository root using this exact structure: + + ```markdown + # PR #{pr_number} Review Summary + + **PR:** {title} + **Branch:** `{branch}` -> `{base}` + **Reviewed:** + **Comments processed:** {total} + + ## Results + + | # | File | Line | Reviewer | Decision | Reasoning | + |---|------|------|----------|----------|-----------| + | 1 | `path/to/file.py` | 42 | reviewer | APPLIED | one sentence | + + ## Applied changes + + For each APPLIED or REVISED item: file name, what changed, and why it + was accepted or how it was adjusted. + + ## Rejected suggestions + + For each REJECTED item: what was suggested, and the specific CLAUDE.md + rule or reasoning that led to rejection. + + ## Observations + + Patterns across the review (recurring style disagreements, areas where + the reviewer's model of the codebase differs from reality, etc.). + ``` + + --- + + {comments_block} + """) + + +# --------------------------------------------------------------------------- +# Claude invocation +# --------------------------------------------------------------------------- + + +def invoke_claude(prompt: str, repo_root: Path) -> None: + """ + Invoke Claude Code non-interactively via stdin. + + Uses stdin rather than a CLI argument to avoid Windows CreateProcess + command-line length limits (~32 KB). Claude's output streams to the + terminal so the developer can follow along. + """ + print("[*] Invoking Claude Code to screen review comments...") + print(" Claude will read source files, assess each suggestion, apply") + print(" accepted edits, and write the summary. This may take a minute.\n") + + try: + subprocess.run( + ["claude", "--print", "--allowedTools", "Read,Edit,Glob,Grep"], + input=prompt, + text=True, + encoding="utf-8", + cwd=repo_root, + check=True, + ) + except FileNotFoundError: + print('[ERROR] "claude" CLI not found.') + print(" Install Claude Code: https://claude.ai/code") + _save_prompt_fallback(prompt, repo_root) + sys.exit(1) + except subprocess.CalledProcessError as exc: + print(f'[ERROR] "claude" exited with status {exc.returncode}.') + _save_prompt_fallback(prompt, repo_root) + sys.exit(exc.returncode) + + +def _save_prompt_fallback(prompt: str, repo_root: Path) -> None: + fallback = repo_root / ".claude_review_prompt.txt" + fallback.write_text(prompt, encoding="utf-8") + print("[*] Prompt saved to .claude_review_prompt.txt") + print(" Run manually with:") + print( + ' claude --print --allowedTools "Read,Edit,Glob,Grep" ' + "< .claude_review_prompt.txt" + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="claude_github_reviews.py", + description=( + "Screen GitHub PR review comments with Claude Code and apply " + "accepted suggestions as pending working-tree changes." + ), + ) + parser.add_argument( + "--pr", + type=int, + required=True, + metavar="NUMBER", + help="Pull request number", + ) + parser.add_argument( + "--repo", + metavar="OWNER/REPO", + default=None, + help="GitHub repo slug (default: inferred from git remote origin)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the prompt that would be sent to Claude and exit without changes", + ) + parser.add_argument( + "--since-last-push", + action="store_true", + dest="since_last_push", + help=( + "Only include inline comments and reviews after the latest reflog time " + "for refs/remotes// (this clone)" + ), + ) + parser.add_argument( + "--remote", + metavar="NAME", + default="origin", + help="Git remote name for reflog (default: origin; used with --since-last-push)", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + repo_root = get_repo_root() + repo: str = args.repo or get_repo_slug(repo_root) + pr_number: int = args.pr + summary_filename = f"pr_{pr_number}_review_summary.md" + + print(f"[*] Repository : {repo}") + print(f"[*] PR : #{pr_number}") + print(f"[*] Summary : {summary_filename}") + print() + + print("[*] Fetching PR metadata...") + pr_data = fetch_pr_data(pr_number, repo) + print(f' "{pr_data.get("title", "")}"') + + print("[*] Fetching inline review comments...") + inline_comments = fetch_inline_comments(pr_number, repo) + print(f" {len(inline_comments)} inline comment(s)") + + print("[*] Fetching PR-level reviews...") + reviews = fetch_reviews(pr_number, repo) + review_bodies = [ + r + for r in reviews + if r.get("body", "").strip() and r.get("state") not in ("PENDING", "") + ] + print(f" {len(review_bodies)} review(s) with body text") + + if args.since_last_push: + head_ref = pr_data.get("head", {}).get("ref") + if not head_ref: + print("[ERROR] PR has no head branch ref; cannot use --since-last-push.") + sys.exit(1) + remote_ref = f"refs/remotes/{args.remote}/{head_ref}" + cutoff = get_remote_reflog_cutoff(repo_root, args.remote, head_ref) + print() + print("[*] --since-last-push") + print(f" Remote ref : {remote_ref}") + print(f" Cutoff : {cutoff.isoformat()}") + inline_comments, reviews = filter_since_cutoff(inline_comments, reviews, cutoff) + review_bodies = [ + r + for r in reviews + if r.get("body", "").strip() and r.get("state") not in ("PENDING", "") + ] + print( + f" After filter: {len(inline_comments)} inline comment(s), " + f"{len(review_bodies)} review(s) with body text" + ) + + total = len(inline_comments) + len(review_bodies) + if total == 0: + print("\n[*] No review comments found. Nothing to do.") + sys.exit(0) + + print(f"\n[*] Building prompt for {total} comment(s)...") + prompt = build_prompt( + pr_number=pr_number, + pr_data=pr_data, + reviews=reviews, + inline_comments=inline_comments, + summary_filename=summary_filename, + ) + + if args.dry_run: + separator = "=" * 60 + if args.since_last_push: + print() + print( + "[dry-run] --since-last-push: using cutoff and counts above " + "(full prompt follows)." + ) + print(f"\n{separator}") + print("PROMPT (dry run — not sent to Claude)") + print(separator) + print(prompt) + print(separator) + print("\n[dry-run] No files changed.") + print(f"[dry-run] Summary would be written to: {summary_filename}") + sys.exit(0) + + invoke_claude(prompt, repo_root) + + print() + summary_path = repo_root / summary_filename + if summary_path.exists(): + print(f"[✓] Summary written : {summary_filename}") + else: + print("[!] Summary file not found — check Claude output above.") + print("[*] Inspect changes : git diff") + print("[*] Stage selectively: git add -p") + + +if __name__ == "__main__": + main() diff --git a/utils/generate_api_map.py b/utils/generate_api_map.py new file mode 100644 index 0000000..52b009c --- /dev/null +++ b/utils/generate_api_map.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python +""" +Generate a structured API index of public Python symbols in the repository. + +Parses all .py source files using the ``ast`` module (no imports required) and +writes a concise Markdown reference to ``docs/API_MAP.md``. Useful for quickly +locating classes, functions, and methods without grepping the full codebase. + +Usage: + python utils/generate_api_map.py [root_dir] + +If root_dir is omitted, uses the parent of the directory containing this +script (i.e. the physiomotion4d project root). +""" + +from __future__ import annotations + +import argparse +import ast +import sys +from pathlib import Path + + +# Directories that are never first-party source code +SKIP_DIRS: set[str] = { + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "build", + "dist", + "htmlcov", + "node_modules", + "venv", + ".venv", +} + +OUTPUT_FILE = "docs/API_MAP.md" + + +# --------------------------------------------------------------------------- +# AST helpers +# --------------------------------------------------------------------------- + + +def first_docstring_line(node: ast.AST) -> str: + """Return the first non-empty line of a class or function docstring.""" + body = getattr(node, "body", []) + if not body: + return "" + first = body[0] + if not isinstance(first, ast.Expr): + return "" + val = first.value + if not isinstance(val, ast.Constant) or not isinstance(val.value, str): + return "" + for line in val.value.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +def format_arg(arg: ast.arg, default: ast.expr | None) -> str: + """Format one argument, appending ``=default`` when a default is present.""" + if default is None: + return arg.arg + try: + default_str = ast.unparse(default) + except Exception: + default_str = "..." + return f"{arg.arg}={default_str}" + + +def format_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: + """Reconstruct a readable signature string from an AST function node.""" + args = node.args + + # Positional args: defaults are right-aligned + n_no_default = len(args.args) - len(args.defaults) + padded: list[ast.expr | None] = [None] * n_no_default + list(args.defaults) + + parts: list[str] = [format_arg(a, d) for a, d in zip(args.args, padded)] + + # *args or bare * separator + if args.vararg: + parts.append(f"*{args.vararg.arg}") + elif args.kwonlyargs: + parts.append("*") + + # Keyword-only args + for arg, default in zip(args.kwonlyargs, args.kw_defaults): + parts.append(format_arg(arg, default)) + + # **kwargs + if args.kwarg: + parts.append(f"**{args.kwarg.arg}") + + prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def" + return f"{prefix} {node.name}({', '.join(parts)})" + + +def is_public(name: str) -> bool: + """Return True for public names; also allow ``__init__``.""" + return name == "__init__" or not name.startswith("_") + + +def module_all_names(tree: ast.Module) -> set[str] | None: + """Return the names listed in ``__all__`` if defined at module level, else None.""" + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + if isinstance(node.value, (ast.List, ast.Tuple)): + names: set[str] = set() + for elt in node.value.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + names.add(elt.value) + return names + return None + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +class MethodEntry: + """A single public method within a class.""" + + __slots__ = ("lineno", "signature", "summary") + + def __init__(self, signature: str, lineno: int, summary: str) -> None: + self.signature = signature + self.lineno = lineno + self.summary = summary + + +class ClassEntry: + """A public class with its public methods.""" + + __slots__ = ("lineno", "methods", "name", "summary") + + def __init__(self, name: str, lineno: int, summary: str) -> None: + self.name = name + self.lineno = lineno + self.summary = summary + self.methods: list[MethodEntry] = [] + + +class FunctionEntry: + """A public module-level function.""" + + __slots__ = ("lineno", "signature", "summary") + + def __init__(self, signature: str, lineno: int, summary: str) -> None: + self.signature = signature + self.lineno = lineno + self.summary = summary + + +class ModuleEntry: + """All public symbols extracted from one source file.""" + + __slots__ = ("classes", "functions", "rel_path") + + def __init__(self, rel_path: str) -> None: + self.rel_path = rel_path + self.classes: list[ClassEntry] = [] + self.functions: list[FunctionEntry] = [] + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def parse_module(path: Path, root: Path) -> ModuleEntry | None: + """Parse *path* and return a :class:`ModuleEntry`, or ``None`` on error / empty.""" + try: + source = path.read_text(encoding="utf-8", errors="replace") + tree = ast.parse(source, filename=str(path)) + except SyntaxError: + return None + + rel_path = path.relative_to(root).as_posix() + entry = ModuleEntry(rel_path) + all_names = module_all_names(tree) + + for node in tree.body: + if isinstance(node, ast.ClassDef): + if all_names is not None and node.name not in all_names: + continue + if not is_public(node.name): + continue + cls_entry = ClassEntry( + name=node.name, + lineno=node.lineno, + summary=first_docstring_line(node), + ) + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not is_public(item.name): + continue + cls_entry.methods.append( + MethodEntry( + signature=format_signature(item), + lineno=item.lineno, + summary=first_docstring_line(item), + ) + ) + entry.classes.append(cls_entry) + + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if all_names is not None and node.name not in all_names: + continue + if not is_public(node.name): + continue + entry.functions.append( + FunctionEntry( + signature=format_signature(node), + lineno=node.lineno, + summary=first_docstring_line(node), + ) + ) + + if not entry.classes and not entry.functions: + return None + return entry + + +def find_python_files(root: Path) -> list[Path]: + """Return sorted .py files under *root*, skipping non-source directories.""" + results: list[Path] = [] + for path in sorted(root.rglob("*.py")): + parts = path.relative_to(root).parts + if any(p in SKIP_DIRS or p.startswith(".") for p in parts): + continue + results.append(path) + return results + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def render_markdown(modules: list[ModuleEntry]) -> str: + """Render *modules* to a Markdown string.""" + lines: list[str] = [ + "# API Map", + "", + "_Generated by `utils/generate_api_map.py`. Do not edit manually._", + "_Re-run `py utils/generate_api_map.py` whenever public APIs change._", + "", + ] + + for mod in modules: + lines.append(f"## {mod.rel_path}") + lines.append("") + + # Interleave classes and functions in source order + items: list[tuple[int, ClassEntry | FunctionEntry]] = [] + for cls in mod.classes: + items.append((cls.lineno, cls)) + for fn in mod.functions: + items.append((fn.lineno, fn)) + items.sort(key=lambda t: t[0]) + + for _, item in items: + if isinstance(item, ClassEntry): + tail = f": {item.summary}" if item.summary else "" + lines.append(f"- **class {item.name}** (line {item.lineno}){tail}") + for method in item.methods: + mtail = f": {method.summary}" if method.summary else "" + lines.append( + f" - `{method.signature}` (line {method.lineno}){mtail}" + ) + else: + tail = f": {item.summary}" if item.summary else "" + lines.append(f"- `{item.signature}` (line {item.lineno}){tail}") + + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate docs/API_MAP.md from Python source files." + ) + parser.add_argument( + "root_dir", + nargs="?", + default=None, + help="Project root directory (default: parent of utils/).", + ) + args = parser.parse_args() + + if args.root_dir is not None: + root = Path(args.root_dir).resolve() + if not root.is_dir(): + print(f"Not a directory: {root}", file=sys.stderr) + return 1 + else: + # Default: parent of the directory containing this script + root = Path(__file__).resolve().parent.parent + + py_files = find_python_files(root) + print(f"Scanning {len(py_files)} Python files under {root}") + + modules: list[ModuleEntry] = [] + for path in py_files: + mod = parse_module(path, root) + if mod is not None: + modules.append(mod) + + print(f"Found public APIs in {len(modules)} modules") + + output_path = root / OUTPUT_FILE + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(render_markdown(modules), encoding="utf-8") + print(f"Written: {output_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/setup_feature_worktree.py b/utils/setup_feature_worktree.py new file mode 100644 index 0000000..7dd86b5 --- /dev/null +++ b/utils/setup_feature_worktree.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python +""" +setup_feature_worktree.py — Automate creation of a Git feature worktree on Windows. + +Workflow: + 1. Validate prerequisites (git, py.exe). + 2. Resolve the repository root and derive default paths. + 3. Sanitize the feature name into a safe branch name and folder name. + 4. Create a new Git branch + worktree. + 5. Create a virtual environment inside the worktree using py.exe. + 6. Install uv into that venv via pip. + 7. Install project dependencies via uv (auto-detected or explicit mode). + 8. Print a summary with the activation command. + +Usage: + py utils/setup_feature_worktree.py my-feature + py utils/setup_feature_worktree.py my-feature --base-branch main + py utils/setup_feature_worktree.py my-feature --worktree-root C:/worktrees + py utils/setup_feature_worktree.py my-feature --dependency-mode editable +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# Shell command helpers +# --------------------------------------------------------------------------- + + +def run( + cmd: list[str], + *, + cwd: Optional[Path] = None, + capture: bool = False, + description: str = "", +) -> subprocess.CompletedProcess[str]: + """Run a command, raising on failure with a helpful message. + + Args: + cmd: Command and arguments as a list of strings. + cwd: Working directory for the command. + capture: If True, capture stdout/stderr instead of letting them print. + description: Human-readable label used in error messages. + + Returns: + The CompletedProcess result. + + Raises: + SystemExit: If the command returns a non-zero exit code. + """ + label = description or " ".join(cmd[:3]) + try: + result = subprocess.run( + cmd, + check=True, + text=True, + cwd=cwd, + capture_output=capture, + ) + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() if exc.stderr else "" + print(f'\n[ERROR] "{label}" failed (exit {exc.returncode}).') + if stderr: + print(f" {stderr}") + sys.exit(exc.returncode) + return result + + +# --------------------------------------------------------------------------- +# Prerequisite checks +# --------------------------------------------------------------------------- + + +def require_tool(name: str) -> str: + """Ensure a tool is on PATH. Returns its resolved path. + + Args: + name: Executable name (e.g. 'git', 'py'). + + Raises: + SystemExit: If the tool cannot be found. + """ + path = shutil.which(name) + if path is None: + print( + f'[ERROR] "{name}" was not found on PATH. Please install it and try again.' + ) + sys.exit(1) + return path + + +def check_prerequisites() -> tuple[str, str]: + """Check that git and py.exe are available on PATH. + + Returns: + Tuple of (git_path, py_path). + """ + print("[*] Checking prerequisites...") + git_path = require_tool("git") + py_path = require_tool("py") + print(f" git : {git_path}") + print(f" py : {py_path}") + return git_path, py_path + + +# --------------------------------------------------------------------------- +# Git helpers +# --------------------------------------------------------------------------- + + +def get_repo_root() -> Path: + """Return the absolute path to the repository root. + + Raises: + SystemExit: If the current directory is not inside a git repository. + """ + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError: + print( + "[ERROR] Not inside a Git repository. Run this script from within a repo." + ) + sys.exit(1) + return Path(result.stdout.strip()) + + +def get_current_branch() -> str: + """Return the name of the currently checked-out branch. + + Falls back to the commit SHA if HEAD is detached. + """ + result = subprocess.run( + ["git", "symbolic-ref", "--short", "HEAD"], + text=True, + capture_output=True, + ) + if result.returncode == 0: + return result.stdout.strip() + # Detached HEAD — use the short SHA instead + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + check=True, + text=True, + capture_output=True, + ) + return result.stdout.strip() + + +def branch_exists(branch_name: str) -> bool: + """Return True if a local branch with this name already exists.""" + result = subprocess.run( + ["git", "branch", "--list", branch_name], + text=True, + capture_output=True, + ) + return bool(result.stdout.strip()) + + +# --------------------------------------------------------------------------- +# Name sanitisation +# --------------------------------------------------------------------------- + + +def sanitize_name(raw: str) -> tuple[str, str]: + """Convert a raw feature name into a safe branch name and folder name. + + Rules applied: + - Lowercase everything. + - Replace whitespace and underscores with hyphens. + - Strip any character that is not alphanumeric, a hyphen, or a dot. + - Collapse consecutive hyphens. + - Strip leading/trailing hyphens and dots (invalid in git branch names). + + Args: + raw: The user-supplied feature name. + + Returns: + (branch_name, folder_name) — both derived from the same sanitised slug. + The branch_name is prefixed with 'feature/' per convention. + + Raises: + SystemExit: If the sanitised name is empty. + """ + slug = raw.lower() + slug = re.sub(r"[\s_]+", "-", slug) # spaces/underscores → hyphens + slug = re.sub(r"[^a-z0-9\-.]", "", slug) # remove unsafe chars + slug = re.sub(r"-{2,}", "-", slug) # collapse runs of hyphens + slug = slug.strip("-.") + + if not slug: + print( + f'[ERROR] Feature name "{raw}" produced an empty slug after sanitisation.' + ) + sys.exit(1) + + branch_name = f"feature/{slug}" + folder_name = slug + return branch_name, folder_name + + +# --------------------------------------------------------------------------- +# Worktree creation +# --------------------------------------------------------------------------- + + +def create_worktree( + worktree_path: Path, + branch_name: str, + base_branch: Optional[str], +) -> None: + """Create a new git branch and worktree. + + Args: + worktree_path: Absolute path where the worktree will be created. + branch_name: New branch to create inside the worktree. + base_branch: Branch or commit to base the new branch on. + If None, the new branch is created from the current HEAD. + + Raises: + SystemExit: If the worktree path already exists or the branch already exists. + """ + if worktree_path.exists(): + print(f"[ERROR] Worktree path already exists: {worktree_path}") + print(" Delete it or choose a different feature name / --worktree-root.") + sys.exit(1) + + if branch_exists(branch_name): + print(f'[ERROR] Branch "{branch_name}" already exists locally.') + print(f" Delete it with: git branch -D {branch_name}") + sys.exit(1) + + print(f'[*] Creating branch "{branch_name}" and worktree at:') + print(f" {worktree_path}") + + cmd = ["git", "worktree", "add", str(worktree_path), "-b", branch_name] + if base_branch: + cmd.append(base_branch) + # When base_branch is omitted, git creates the branch from HEAD automatically. + + run(cmd, description=f"git worktree add ({branch_name})") + print(" Done.") + + +# --------------------------------------------------------------------------- +# Venv + uv setup +# --------------------------------------------------------------------------- + + +def create_venv(worktree_path: Path, py_path: str) -> Path: + """Create a virtual environment inside the worktree. + + Args: + worktree_path: Root of the worktree. + py_path: Absolute path to py.exe. + + Returns: + Path to the venv directory. + """ + venv_dir = worktree_path / "venv" + print(f"[*] Creating virtual environment at: {venv_dir}") + run( + [py_path, "-m", "venv", str(venv_dir)], + cwd=worktree_path, + description="py -m venv", + ) + print(" Done.") + return venv_dir + + +def install_uv(venv_dir: Path) -> Path: + """Install uv into the venv using pip. + + Args: + venv_dir: Path to the venv directory. + + Returns: + Path to the uv.exe executable inside the venv. + """ + venv_python = venv_dir / "Scripts" / "python.exe" + uv_exe = venv_dir / "Scripts" / "uv.exe" + + if not venv_python.exists(): + print(f"[ERROR] Expected venv Python not found: {venv_python}") + sys.exit(1) + + print("[*] Installing uv into the virtual environment...") + run( + [str(venv_python), "-m", "pip", "install", "--quiet", "uv"], + description="pip install uv", + ) + + if not uv_exe.exists(): + print(f"[ERROR] uv.exe not found after installation: {uv_exe}") + sys.exit(1) + + print(" Done.") + return uv_exe + + +# --------------------------------------------------------------------------- +# Dependency detection and installation +# --------------------------------------------------------------------------- + + +def detect_dependency_mode(worktree_path: Path) -> str: + """Auto-detect the best dependency installation mode for the project. + + Detection order: + 1. requirements.txt → 'requirements' + 2. pyproject.toml → 'pyproject' + 3. fallback → 'editable' + + Args: + worktree_path: Root of the worktree. + + Returns: + One of 'requirements', 'pyproject', 'editable'. + """ + if (worktree_path / "requirements.txt").exists(): + return "requirements" + if (worktree_path / "pyproject.toml").exists(): + return "pyproject" + return "editable" + + +def install_dependencies( + uv_exe: Path, + worktree_path: Path, + mode: str, +) -> None: + """Install project dependencies using uv. + + Args: + uv_exe: Absolute path to the uv executable inside the venv. + worktree_path: Root of the worktree (used as the working directory). + mode: One of 'requirements', 'pyproject', 'editable', 'auto'. + + Raises: + SystemExit: If the mode is unrecognised or required files are missing. + """ + if mode == "auto": + mode = detect_dependency_mode(worktree_path) + print(f"[*] Auto-detected dependency mode: {mode}") + + print(f"[*] Installing dependencies (mode: {mode})...") + + if mode == "requirements": + req_file = worktree_path / "requirements.txt" + if not req_file.exists(): + print(f"[ERROR] requirements.txt not found at: {req_file}") + sys.exit(1) + run( + [str(uv_exe), "pip", "install", "-r", str(req_file)], + cwd=worktree_path, + description="uv pip install -r requirements.txt", + ) + + elif mode == "pyproject": + # pyproject.toml is present but we cannot assume a uv lockfile exists or + # that the project uses uv's project workflow (uv sync). The most robust + # fallback that works with any PEP 517 build backend is an editable install, + # which reads [project.dependencies] from pyproject.toml and installs them. + # If the caller truly wants a non-editable install, they should use + # --dependency-mode editable and adjust afterwards. + pyproject_file = worktree_path / "pyproject.toml" + if not pyproject_file.exists(): + print(f"[ERROR] pyproject.toml not found at: {pyproject_file}") + sys.exit(1) + print( + " (pyproject mode: using editable install to read [project.dependencies])" + ) + run( + [str(uv_exe), "pip", "install", "-e", "."], + cwd=worktree_path, + description="uv pip install -e . (pyproject)", + ) + + elif mode == "editable": + run( + [str(uv_exe), "pip", "install", "-e", "."], + cwd=worktree_path, + description="uv pip install -e .", + ) + + else: + print(f'[ERROR] Unknown dependency mode: "{mode}".') + print(" Valid values: auto, requirements, pyproject, editable") + sys.exit(1) + + print(" Done.") + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def print_summary( + branch_name: str, + worktree_path: Path, + venv_dir: Path, +) -> None: + """Print a formatted summary of the created worktree environment.""" + venv_python = venv_dir / "Scripts" / "python.exe" + + separator = "=" * 60 + print(f"\n{separator}") + print(" Worktree setup complete!") + print(separator) + print(f" Branch : {branch_name}") + print(f" Worktree : {worktree_path}") + print(f" Venv Python : {venv_python}") + print() + print(" To activate the venv in PowerShell, run:") + print(f' cd "{worktree_path}"') + print(" .\\venv\\Scripts\\Activate.ps1") + print() + print(" Or directly invoke the Python interpreter:") + print(f' "{venv_python}" your_script.py') + print(separator) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + + +def parse_args() -> argparse.Namespace: + """Parse and return command-line arguments.""" + parser = argparse.ArgumentParser( + prog="setup_feature_worktree.py", + description="Create a Git feature worktree with a Python venv for Windows.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + py utils/setup_feature_worktree.py my-cool-feature + py utils/setup_feature_worktree.py my-cool-feature --base-branch main + py utils/setup_feature_worktree.py my-cool-feature --worktree-root C:/worktrees + py utils/setup_feature_worktree.py my-cool-feature --dependency-mode requirements +""", + ) + + parser.add_argument( + "feature_name", + help="Feature name (will be sanitised into a branch name and folder name).", + ) + parser.add_argument( + "--worktree-root", + metavar="DIR", + default=None, + help=( + "Parent directory in which to create the worktree folder. " + "Default: /-worktrees/" + ), + ) + parser.add_argument( + "--base-branch", + metavar="BRANCH", + default=None, + help=( + "Branch or commit to base the new branch on. " + "Default: the currently checked-out branch / HEAD." + ), + ) + parser.add_argument( + "--dependency-mode", + metavar="MODE", + default="auto", + choices=["auto", "requirements", "pyproject", "editable"], + help=( + "How to install dependencies. " + "auto (default): detect from project files. " + "requirements: use requirements.txt. " + "pyproject: use pyproject.toml (via editable install). " + "editable: pip install -e ." + ), + ) + + return parser.parse_args() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """Main entry point for the worktree setup script.""" + args = parse_args() + + # --- Prerequisites --- + git_path, py_path = check_prerequisites() + + # --- Resolve repository root --- + repo_root = get_repo_root() + repo_name = repo_root.name + print(f"[*] Repository root : {repo_root}") + + # --- Derive default worktree root --- + if args.worktree_root is not None: + worktree_root = Path(args.worktree_root) + else: + # Place worktrees alongside the repo: /-worktrees/ + worktree_root = repo_root.parent / f"{repo_name}-worktrees" + + # --- Sanitise feature name --- + branch_name, folder_name = sanitize_name(args.feature_name) + worktree_path = worktree_root / folder_name + + print(f"[*] Branch name : {branch_name}") + print(f"[*] Worktree path : {worktree_path}") + + # --- Determine base branch --- + base_branch: Optional[str] = args.base_branch + if base_branch is None: + base_branch_display = get_current_branch() + print(f"[*] Base branch : {base_branch_display} (current HEAD)") + else: + print(f"[*] Base branch : {base_branch} (explicit)") + + # Ensure the worktree root directory exists before creating the worktree. + worktree_root.mkdir(parents=True, exist_ok=True) + + # --- Create worktree --- + create_worktree(worktree_path, branch_name, base_branch) + + # --- Create venv --- + venv_dir = create_venv(worktree_path, py_path) + + # --- Install uv --- + uv_exe = install_uv(venv_dir) + + # --- Install dependencies --- + install_dependencies(uv_exe, worktree_path, args.dependency_mode) + + # --- Summary --- + print_summary(branch_name, worktree_path, venv_dir) + + +if __name__ == "__main__": + main()