diff --git a/Cargo.lock b/Cargo.lock index 2db2d91..7d0a97b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6413,7 +6413,7 @@ dependencies = [ [[package]] name = "rust-dicom-station" -version = "0.6.0" +version = "0.6.5" dependencies = [ "anyhow", "burn", diff --git a/Cargo.toml b/Cargo.toml index e2acf7a..24eca28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-dicom-station" -version = "0.6.5" +version = "0.7.0" edition = "2021" description = "Fast, robust DICOM / RT DICOM viewer in pure Rust (CT/MR volumes, RTSTRUCT, RTDOSE, RTPLAN) with a three-view MPR layout" license = "MIT" diff --git a/README.md b/README.md index 479ca1b..27fc3cb 100644 --- a/README.md +++ b/README.md @@ -2,139 +2,83 @@ [![CI](https://github.com/alexprotom/rust-dicom-station/actions/workflows/ci.yml/badge.svg)](https://github.com/alexprotom/rust-dicom-station/actions/workflows/ci.yml) -RDS (Rust DICOM Station) is a fast, robust DICOM / RT DICOM viewer written **entirely in Rust**. It -loads a full radiotherapy study: image series (CT/MRI/PET), RT Structure -Set, RT Dose, RT Plan (photon and ion/proton), planar images, spatial -registrations, treatment records; and displays it in the classic -three-view layout, with a second dataset row for comparison, built-in -**image registration** (elastix- and plastimatch-style, rigid, deformable -and landmark-based, with analytics and a deformation vector field), -**structure propagation**, **DRR generation**, **interactive -segmentation**, a live **3D structure view**, **automatic body / EXTERNAL -contouring** (the patient outline without the couch, the chair or the -immobilisation, on CT and MR), **automatic multi-organ -segmentation** (a pure-Rust re-implementation of TotalSegmentator, 117 -structures, CPU or any GPU), **prompt-driven segmentation** (a -pure-Rust re-implementation of SegVol — point at anything with a box, a -click or a structure name, and get an editable mask back), and **slice -propagation** (a pure-Rust re-implementation of MedSAM2 — mark a structure -on one slice and follow it through the whole stack). +RDS (Rust DICOM Station) is an open-source DICOM workstation for radiotherapy research, analysis, and QA, **written entirely in Rust**. It loads complete radiotherapy studies (CT, MR and PET series, RTSTRUCT, RTDOSE, photon and ion RTPLAN, DICOM SEG, planar images, spatial and deformable registrations, and treatment records) into an integrated environment for visualization, comparison and quantitative analysis. Beyond the classic linked three-view layout and dual-dataset comparison, RDS provides image registration, structure propagation, DRR generation, dose-volume histograms, 4D motion analysis, interactive and AI-assisted segmentation, 3D visualization, and DICOM editing and export. The entire processing stack is native Rust: functionality normally provided through C/C++ or Python frameworks, including elastix- and plastimatch-style registration, ITK-style ray casting, TotalSegmentator, SegVol, and MedSAM2, is re-implemented directly in Rust without bindings to those frameworks. ![overview](docs/screenshot_overview.png) -*One session, the bundled 4D-Lung patient: datasets A and B are two -breathing phases of a 4DCT shown as two rows of linked MPR views with -their phase-specific RTSTRUCT contours; the crosshair sits in the tumor. -The floating window is the live 3D view of dataset A — RTSTRUCT surfaces -(lungs, heart, tumor, cord) together with organs auto-segmented by the -built-in TotalSegmentator engine, which also fills the Segmentations list -in the sidebar (aorta, trachea, liver, stomach, spleen, kidneys — with -volumes, editable as masks, convertible to RTSTRUCT). The sidebar also -holds the registration controls and both dataset trees.* - -## Highlights - -* **Viewing** - parallel DICOM loading (incl. compressed syntaxes), true - patient-space geometry, axial/sagittal/coronal with linked crosshairs, - window/level with CT presets, dose colorwash + isodose lines, per-beam - plan summaries, planar images (DX/CR/RTIMAGE), dark/light themes. -* **Datasets** - a patient ▶ study ▶ series tree per dataset, folder - merging, copy/move/remove with correct reference-chain semantics, - RT structure sets and segmentation series as tree nodes (create one, - connect it to another image series, move whole series or single - structures / segments between any two of them - contours and masks - converting as they cross), renaming at every level from patient down to a - single segment, six-view comparison mode with patient-space crosshair - linking. -* **Registration** - four engines, none of them a binding: rigid (6-DOF) - and deformable (cubic B-spline) re-implemented from **elastix** - (multi-resolution pyramids, stochastic sampling, ASGD); a dense - **plastimatch** B-spline with the exact analytic gradient, a - bending-energy regularizer, L-BFGS and a choice of mean squares or - **Mattes mutual information** for CT-MR; and plastimatch's **landmark - warp** (thin-plate spline, Gaussian, Wendland). Any of them can be - restricted to a single structure, or refined on top of an existing - result - a local deformation is provably zero outside its region. - Every run reports its **six degrees of freedom**, displacement - statistics, Jacobian determinant and folding, and per-structure - displacement; the **deformation vector field** draws as arrows or a - deformed grid in all views and as glyphs in 3D, where both datasets can - stand in one scene with independent opacity. Magenta/green fusion - overlay, DICOM REG *and* Deformable Spatial Registration read and - written, a known-transform simulator for QA, sub-millimeter verified - accuracy. -* **Structure propagation** - contours and segmentations carried between - datasets through any registration, pulled back per destination voxel - (no holes, sub-voxel boundaries, any two grids), with an optional local - refinement on the enclosing structure first - which is what makes a - small structure inside a larger one land where it belongs. Results - arrive as ordinary editable segmentations, convertible to RTSTRUCT. -* **DRR generation** - two independent forward projectors on one IEC - cone-beam geometry (beam's-eye view straight from an RTPLAN beam): - plastimatch's **exact Siddon** voxel-intersection ray tracing, and - ITK's **interpolating ray-cast**. Side by side with a signed difference - image and its statistics - the honest measure of what either one costs - you. -* **Segmentation** - spacing-aware 2D/3D brush and eraser, geodesic - region growing with live preview, per-stroke undo, real-time 3D surface - view, mask → RTSTRUCT conversion, and **DICOM SEG** import and export - (binary and fractional multi-frame masks, read onto their own lattice - and resampled onto whichever image series they belong to). -* **Body contouring** - the EXTERNAL structure, found automatically and - without the couch, the chair or the immobilisation inside it. Equipment - is separated from anatomy by two facts no patient has together: it is - thin, and its footprint repeats slice after slice - so an 8 mm opening - and a persistence test along **all three** axes catch a supine couch top - and an upright chair's seat pan alike, while ears, nose and fingers are - given back afterwards. Works on CT by Hounsfield threshold and on MR - after flattening the coil shading; optionally guided by - TotalSegmentator's openly licensed body network, which is what removes a - mask touching the skin with no gap. Lands as an editable mask and as an - RTSTRUCT `EXTERNAL`. -* **Auto-segmentation** - TotalSegmentator v2 inference rebuilt natively: - official nnU-Net weights downloaded once and converted - without Python, hand-written SIMD CPU engine and a wgpu GPU path - (Vulkan/DX12/Metal, no CUDA), validated to mean Dice 0.9995 against the - reference implementation. -* **Prompt segmentation** - SegVol (NeurIPS 2024) rebuilt natively: a - 181 M-parameter 3-D ViT with a SAM-style prompt encoder and mask - decoder plus a CLIP text tower, prompted with a **box**, a **click**, - or **free text** ("liver", "tumor"…) — for the structures no - fixed-class model can cover: lesions, targets, post-surgical cavities. - Two-pass zoom-out / zoom-in inference, the image encoder on the same - no-CUDA wgpu GPU path, results landing as ordinary editable - segmentations, convertible to RTSTRUCT. -* **Slice propagation** - MedSAM2 (2025) rebuilt natively: SAM 2.1 with its - memory bank, so a structure boxed on **one** slice is followed through the - rest of the stack at the slice's own resolution - no in-plane resampling at - all on 512x512 CT. The box is **drawn in the image and stays there** with - handles to resize and move it; the prompted slice previews on its own and - takes include / exclude clicks until it is right, and a slice that drifts is - corrected by boxing it again and re-running into the same segmentation. - Validated against the reference implementation module by module and over a - full propagation. -* **Tools** - DICOM export with an editable patient/study tag table - (CT + RTSTRUCT + SEG + RTDOSE + RTPLAN), a **model manager** showing every - downloadable network weight with its state and size and the buttons to - download, update, remove or free one or all of them, an interactive - folder anonymizer with consistent UID regeneration, and a synthetic - RT-study generator; 280+ tests across nine integration suites assert the - whole stack against an analytically known phantom, on Linux and Windows - in CI. +*The bundled 4D-Lung patient: two breathing phases as two rows of linked MPR +views with their RTSTRUCT contours, and the 3D window showing the RTSTRUCT +surfaces together with organs auto-segmented by the built-in TotalSegmentator +engine.* + +## What it does + +* **Viewing** - parallel DICOM loading (compressed syntaxes included), true + patient-space geometry, linked axial / sagittal / coronal views, W/L + presets, dose colorwash and isodose lines, per-beam plan summaries, planar + images (DX / CR / RTIMAGE), dark and light themes. +* **Datasets** - a patient ▶ study ▶ series tree per dataset; copy / move / + remove / rename at every level with the reference chains kept intact; RT + structure sets and segmentation series as tree nodes, contours and masks + converting as they move between them; six-view comparison mode. +* **Patient archive** - a local PACS on plain folders and text sidecars: + file a study, list patients without opening a DICOM file, load into either + dataset, and send the structures and segmentations you drew back as derived + objects under the original Study and Frame of Reference UIDs. +* **Registration** - rigid and B-spline after **elastix** (pyramids, + stochastic sampling, ASGD), dense B-spline after **plastimatch** (analytic + gradient, bending energy, L-BFGS, mean squares or Mattes mutual + information) and plastimatch's **landmark warp**; any of them restricted to + one structure or refined on top of a previous result. Every run reports its + 6 DOF, displacement statistics, Jacobian determinant and folding; the vector + field draws in the views and in 3D; fusion overlay; DICOM REG and Deformable + Spatial Registration read and written; a known-transform simulator for QA. +* **Structure propagation** - contours and segmentations carried through a + registration by per-voxel pull-back (no holes, any two grids), optionally + refined on an enclosing structure first. +* **4D / motion** - phases recognised into 4D groups; the reference phase + registered to every other, targets propagated and their centroids tracked; + peak-to-peak, drift, correlation with a reference structure, ITV + generation, a results window with run-vs-run comparison and CSV export; + structure comparison (Dice, HD95, surface distance) and transfer by + relationship. +* **DRR** - plastimatch's exact Siddon tracer and ITK's interpolating + ray-cast on one IEC cone-beam geometry, beam's-eye view from an RTPLAN + beam, side by side with their difference. +* **Dose-volume histograms** - cumulative and differential DVHs of any + structures against any dose, sampled on the structure's own lattice; + `D95%` / `D2cc` / `V20Gy` metrics, protocol constraint checking, CSV + export; verified against an analytic phantom. +* **Segmentation** - spacing-aware 2D / 3D brush and eraser, geodesic region + growing, undo, live 3D surfaces, mask ⇄ RTSTRUCT, DICOM SEG import and + export (binary and fractional). +* **Structure algebra** - union / intersection / subtraction / symmetric + difference with margins in patient directions (exact ellipsoids), crop, + ring, cleanup. +* **Body contour** - the EXTERNAL structure without the couch, the chair or + the mask, on CT and MR, classically or guided by TotalSegmentator's body + network. +* **Auto-segmentation** - TotalSegmentator v2 rebuilt natively (117 + structures): official nnU-Net weights converted without Python, a SIMD CPU + engine or a wgpu GPU path (no CUDA), mean Dice 0.9995 against the + reference. +* **Prompt segmentation** - SegVol rebuilt natively: box, click or free-text + prompts ("liver", "tumor") for the structures no fixed-class model covers. +* **Slice propagation** - MedSAM2 (SAM 2.1 with its memory bank) rebuilt + natively: box a structure on one slice, refine with include / exclude + clicks, follow it through the stack at native resolution. +* **Tools** - DICOM export with an editable tag table, a model manager for + every downloadable weight, a folder anonymizer with consistent UID + regeneration, a synthetic RT-study generator; every tool window can be + moved to its own monitor. ## Architecture -One language, one binary. Every algorithm - DICOM parsing, volume -reconstruction, rendering primitives, registration, meshing, neural-net -inference, DICOM writing - is implemented in Rust; where a feature -usually means binding a C/C++ library (ITK/elastix, ONNX Runtime, CUDA), -it is re-implemented natively instead - elastix and plastimatch -registration, ITK forward projection, TotalSegmentator, SegVol and MedSAM2 -inference all included. Image processing runs CPU-side -with `rayon` and aggressive caching; the GPU (via `wgpu`) only blits the -UI and, optionally, runs the segmentation networks. Long operations run on -background threads with progress and cancellation. The full module map, -threading model, geometry conventions and performance numbers are in +One language, one binary. All image processing runs on the CPU with `rayon` +and caching; the GPU (`wgpu`: DX12 / Vulkan / Metal) blits the UI and, +optionally, runs the networks. Long operations run on worker threads with +progress and cancellation. The module map, threading model, geometry +conventions and test suites are in [docs/architecture.md](docs/architecture.md). ## Quick start @@ -143,34 +87,27 @@ Requires a Rust toolchain (). ``` cargo build --release - -# open a study, or two studies straight into comparison mode: cargo run --release -- example_data/lung_p1_4DCT_phase_000 cargo run --release -- example_data/lung_p1_4DCT_phase_000 example_data/lung_p1_4DCT_phase_050 - cargo test --release ``` -To try prompt segmentation on the bundled patient: put the crosshair on -the tumor, then *Tools ▶ 🧠 Prompt-segment dataset A…*, prompt **Box**, -**▶ Segment**. All three segmentation engines fetch their weights on first -use into one folder, `models/` next to the executable (one sub-folder per -engine, movable from any of the tool windows); all three also have -headless CLIs in [examples/](examples/). - -Windows, Linux and macOS are supported; rendering uses `wgpu` -(DX12/Vulkan/Metal). `--no-default-features` builds a CPU-only viewer -without the GPU inference backend. - -On Windows there is also a proper installer — a single -`rust-dicom-station-setup.exe` with shortcuts, an "Open with" entry on -folders, the Visual C++ runtime check, an optional pre-download of the -auto-segmentation weights, and a clean uninstall. It is a separate Rust -program in [installer/](installer/README.md) and is *not* built by -`cargo build --release`; see its README for the three build steps. No data at hand? *File ▶ 🧪 Generate -test data…* creates a complete synthetic RT study, and `example_data/` -ships a real two-phase 4DCT (see -[docs/example-data.md](docs/example-data.md)). +To try prompt segmentation on the bundled patient: put the crosshair on the +tumor, *Tools ▶ 🧠 Prompt-segment dataset A…*, prompt **Box**, **▶ Segment**. +The engines fetch their weights on first use into one model folder +(`%LOCALAPPDATA%\RustDICOMStation\models` on Windows, +`~/.local/share/RustDICOMStation/models` on Linux), movable from any tool +window; each engine also has a headless CLI in [examples/](examples/). + +Windows, Linux and macOS are supported; `--no-default-features` builds a +CPU-only viewer without the GPU inference backend. Every push to `main` +publishes a release: a Windows installer +(`rust-dicom-station--windows-x86_64.exe` — shortcuts, "Open with" +on folders, the VC++ runtime check, optional weight prefetch, uninstaller) +and a Linux AppImage. The installer is its own crate in +[installer/](installer/README.md). No data at hand? *File ▶ 🧪 Generate test +data…* writes a complete synthetic RT study, and `example_data/` ships a real +two-phase 4DCT ([docs/example-data.md](docs/example-data.md)). ## Documentation @@ -180,30 +117,35 @@ ships a real two-phase 4DCT (see | [docs/rt-objects.md](docs/rt-objects.md) | RTSTRUCT, RTDOSE, RTPLAN, REG, RTRECORD, reference chains | | [docs/registration.md](docs/registration.md) | The four registration engines, local registration, analytics, vector fields, fusion, simulator, verification | | [docs/propagation.md](docs/propagation.md) | Carrying contours and segmentations across a registration | +| [docs/motion-4d.md](docs/motion-4d.md) | 4D groups, the motion / ITV workflow, results, structure comparison and transfer | | [docs/drr.md](docs/drr.md) | Digitally reconstructed radiographs: the two projectors and the geometry | +| [docs/dvh.md](docs/dvh.md) | Dose-volume histograms: curves, metrics, constraint checking, export | | [docs/segmentation.md](docs/segmentation.md) | Brush / eraser / region growing, 3D view, mask → RTSTRUCT | -| [docs/body-contour.md](docs/body-contour.md) | The body / EXTERNAL contour: the classical and model-assisted methods, CT and MR, verification | -| [docs/segvol.md](docs/segvol.md) | Prompt-driven segmentation: box / point / text, the SegVol re-implementation | -| [docs/medsam2.md](docs/medsam2.md) | Propagating a prompt through a stack: the MedSAM2 re-implementation | +| [docs/structure-algebra.md](docs/structure-algebra.md) | Boolean operations, margins, cropping, cleanup | +| [docs/body-contour.md](docs/body-contour.md) | The body / EXTERNAL contour on CT and MR, verification | | [docs/auto-segmentation.md](docs/auto-segmentation.md) | The pure-Rust TotalSegmentator: models, pipeline, engines, validation, classes, licensing | +| [docs/segvol.md](docs/segvol.md) | Prompt-driven segmentation: the SegVol re-implementation | +| [docs/medsam2.md](docs/medsam2.md) | Propagating a prompt through a stack: the MedSAM2 re-implementation | +| [docs/pacs.md](docs/pacs.md) | The local patient archive: window, on-disk layout, filing, loading, sending changes back | | [docs/export-and-tools.md](docs/export-and-tools.md) | DICOM export, the model manager, anonymizer, test-data generator | | [docs/architecture.md](docs/architecture.md) | Design, functional overview, module map, threading, the model folder, conventions, testing | +| [docs/release-versioning.md](docs/release-versioning.md) | How versions and releases are produced | | [docs/example-data.md](docs/example-data.md) | Bundled patient data, source and citations | | [installer/README.md](installer/README.md) | The Windows installer: building it, what it installs, silent switches | ## License and citations The code is MIT-licensed. The bundled example data is TCIA **4D-Lung** -patient P102, redistributed under CC BY 3.0 — cite it as described in -[docs/example-data.md](docs/example-data.md). The auto-segmentation uses -TotalSegmentator's openly licensed (Apache-2.0) "total"-task weights — -cite Wasserthal et al. (Radiology AI 2023) and nnU-Net (Isensee et al., -Nature Methods 2021) as described in -[docs/auto-segmentation.md](docs/auto-segmentation.md). Prompt -segmentation re-implements SegVol (Du et al., NeurIPS 2024); its weights -carry **no license declaration**, so they are only ever downloaded from -Hugging Face to your own machine at your request and are never -redistributed — see [docs/segvol.md](docs/segvol.md). - -This software is a viewer for research and QA convenience — **not a -medical device, and not for clinical decision-making.** +patient P102, redistributed under CC BY 3.0 (cite it as described in +[docs/example-data.md](docs/example-data.md)). Auto-segmentation uses +TotalSegmentator's Apache-2.0 "total"-task weights (cite Wasserthal et al. +(Radiology AI 2023) and nnU-Net (Isensee et al., Nature Methods 2021) as +described in [docs/auto-segmentation.md](docs/auto-segmentation.md)). Prompt +segmentation re-implements SegVol (Du et al., NeurIPS 2024) and slice +propagation MedSAM2 (Ma et al., 2025); their weights are only ever +downloaded from Hugging Face to your own machine at your request and are +never redistributed; see [docs/segvol.md](docs/segvol.md) and +[docs/medsam2.md](docs/medsam2.md). + +This software is a viewer for research and QA convenience. **Not a medical +device, and not for clinical decision-making.** diff --git a/docs/README.md b/docs/README.md index 78b1bc6..81ffc93 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,18 +1,26 @@ # Documentation -Comprehensive documentation for **rust-dicom-station**. Start with the -[main README](../README.md) for the overview and quick start; each page here -covers one area in depth. +Start with the [main README](../README.md) for the overview and quick start; +each page here covers one area. | Page | Contents | |---|---| -| [viewer.md](viewer.md) | Loading DICOM data, volume reconstruction, the three-view MPR layout, window/level, the patient ▶ study ▶ series tree, comparison mode, planar images, interaction bindings, appearance | -| [rt-objects.md](rt-objects.md) | RT DICOM objects: RTSTRUCT, RTDOSE, RTPLAN, REG spatial registrations, RT treatment records, and how their reference chains are resolved | -| [registration.md](registration.md) | Rigid and deformable (B-spline) image registration: algorithms, parameters, the fusion overlay, the transform simulator for registration QA, accuracy verification | -| [segmentation.md](segmentation.md) | Interactive segmentation: 2D/3D brush, eraser, geodesic region growing, the live 3D structure view, mask → RTSTRUCT conversion | -| [body-contour.md](body-contour.md) | Automatic body / EXTERNAL contouring: why the couch, the chair and the mask are the hard part, the classical threshold-and-morphology method, the model-assisted method built on TotalSegmentator's body network, CT and MR, verification | -| [auto-segmentation.md](auto-segmentation.md) | Automatic multi-organ segmentation — the pure-Rust TotalSegmentator re-implementation: models, usage, the inference pipeline, CPU/GPU engines, validation, the full 117-class table, licensing | -| [segvol.md](segvol.md) | Prompt-driven segmentation — the pure-Rust SegVol re-implementation: box / point / text prompts, the two-pass pipeline, weights and licensing, validation status | -| [export-and-tools.md](export-and-tools.md) | DICOM export, the interactive anonymizer, the synthetic test-data generator | -| [architecture.md](architecture.md) | Code architecture: design philosophy, the functional overview (what the program does, by category), the module map (where each function lives), the shared engine windows, threading model, the model folder, caching, geometry conventions, testing | -| [example-data.md](example-data.md) | The bundled example patient data: contents, source, citations, license | +| [viewer.md](viewer.md) | Loading, volume reconstruction, the MPR layout, window/level, the patient ▶ study ▶ series tree, comparison mode, planar images, interaction bindings, appearance | +| [rt-objects.md](rt-objects.md) | RTSTRUCT, RTDOSE, RTPLAN, REG, RT treatment records, and how their reference chains are resolved | +| [registration.md](registration.md) | The four registration engines, local registration, analytics, vector fields, fusion, the transform simulator, verification | +| [propagation.md](propagation.md) | Carrying contours and segmentations across a registration | +| [motion-4d.md](motion-4d.md) | 4D groups, the per-phase register ▸ propagate ▸ measure pipeline, motion metrics, ITV generation, the results window, structure comparison and transfer | +| [drr.md](drr.md) | Digitally reconstructed radiographs: the two projectors, the geometry, the comparison | +| [dvh.md](dvh.md) | Dose–volume histograms: sampling, axes, metrics, protocol constraints, CSV export, the analytic phantom | +| [segmentation.md](segmentation.md) | Interactive segmentation: brush, eraser, geodesic region growing, the 3D view, mask → RTSTRUCT | +| [structure-algebra.md](structure-algebra.md) | Boolean operations, margins in patient directions, cropping, cleanup | +| [body-contour.md](body-contour.md) | The body / EXTERNAL contour: the classical and the model-assisted method, CT and MR, verification | +| [auto-segmentation.md](auto-segmentation.md) | The pure-Rust TotalSegmentator: models, pipeline, CPU/GPU engines, validation, the 117 classes, licensing | +| [segvol.md](segvol.md) | Prompt-driven segmentation: box / point / text, the SegVol re-implementation, weights and licensing | +| [medsam2.md](medsam2.md) | Slice propagation: the MedSAM2 re-implementation, validation, weights and licensing | +| [pacs.md](pacs.md) | The local patient archive: the window, the on-disk layout, filing, loading, sending changes back | +| [export-and-tools.md](export-and-tools.md) | DICOM export, the model manager, the anonymizer, the test-data generator | +| [architecture.md](architecture.md) | Design, functional overview, module map, the tool windows, background jobs, the model folder, conventions, testing | +| [release-versioning.md](release-versioning.md) | Versioning, the branch workflow, how CI produces a release | +| [example-data.md](example-data.md) | The bundled example patient: contents, source, citations, license | +| [early-detection-plan.md](early-detection-plan.md) | Plan: integrating early cancer detection methods (CT) | diff --git a/docs/architecture.md b/docs/architecture.md index 0de3639..5f89128 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,177 +2,180 @@ ## Design philosophy -**One language.** Everything is Rust — DICOM parsing, image -reconstruction, rendering primitives, registration, meshing, neural-net -inference, DICOM writing. Where a capability normally means binding a -C/C++ library (elastix, ITK, ONNX Runtime, CUDA), the algorithms are -re-implemented natively instead. The only system interface is the GPU, -reached twice through `wgpu` (Vulkan / DX12 / Metal): once by `eframe` to -blit the UI, once (optionally) by `burn` for neural-network inference — -auto-segmentation, SegVol's image encoder, and the whole MedSAM2 graph — -no vendor SDKs either way. - -**CPU-side algorithms, GPU-side pixels.** All image processing runs on -the CPU with `rayon` data parallelism and aggressive caching; the GPU -receives finished textures. This keeps every algorithm debuggable, -deterministic and portable, and turns out to be fast enough: full study -load ≈ 40 ms, orthogonal slice extraction ≈ 6 µs, dose-plane resampling -≈ 0.3 ms (measured on the synthetic study). - -**Long work never blocks the UI.** Anything that can take more than a -frame — loading, registration, meshing, simulation, export, anonymization, -the three segmentation engines — runs on a worker thread and reports -through one shared progress handle (see [Background jobs](#background-jobs)). - -**Shared before specific.** What more than one feature needs lives one -level up: the progress handle, the model folder, the checkpoint -download / conversion / cache path, the device choice, the shape-checked -parameter view and the dense CPU kernels are written once in -`progress.rs`, `models.rs` and `nn/`; the three engines and the three tool -windows are built on top of them and hold only what is theirs. +**One language.** Everything is Rust — DICOM parsing, image reconstruction, +rendering primitives, registration, meshing, neural-net inference, DICOM +writing. Where a capability normally means binding a C/C++ library (elastix, +ITK, ONNX Runtime, CUDA), the algorithm is re-implemented natively. The only +system interface is the GPU, reached through `wgpu` (Vulkan / DX12 / Metal) +by `eframe` to blit the UI and, optionally, by `burn` to run the networks. + +**CPU-side algorithms, GPU-side pixels.** Image processing runs on the CPU +with `rayon` and aggressive caching; the GPU receives finished textures. +Every algorithm stays debuggable, deterministic and portable, and it is fast +enough: study load ≈ 40 ms, orthogonal slice ≈ 6 µs, dose-plane resampling +≈ 0.3 ms on the synthetic study. + +**Long work never blocks the UI.** Anything longer than a frame runs on a +worker thread and reports through one progress handle +([Background jobs](#background-jobs)). + +**Shared before specific.** What more than one feature needs lives one level +up: the progress handle, the model folder, the checkpoint download / +conversion / cache path, the device choice, the shape-checked parameter view +and the dense CPU kernels are written once (`progress.rs`, `models.rs`, +`nn/`); the engines and the tool windows hold only what is theirs. ## Functional overview -What the program does, by category. Every leaf exists in the code today; the -module map below says where. +What the program does, by category; the [module map](#module-map) says +where each leaf lives. ``` rust-dicom-station │ ├── Application (GUI, egui over wgpu) │ ├── Window chrome: menu bar, toolbar (W/L, presets, 3D, crosshair, reset), status bar -│ ├── Side panel: registration, simulation, and per dataset a DICOM tree — -│ │ patient ▶ study ▶ modality (CT/MR/US…) ▶ series, with RT structures, -│ │ segmentations, dose and plans inside their own study — plus the -│ │ dataset-level dose display, planar images, spatial registrations, -│ │ treatment records and warnings +│ ├── Side panel: the optional registration and simulation sections, and per +│ │ dataset a DICOM tree — patient ▶ study ▶ modality ▶ series, with RT +│ │ structures, segmentations, 4D groups, dose and plans inside their study — +│ │ plus dose display, planar images, spatial registrations, records, warnings │ ├── Views: 1 × 3 or 2 × 3 (comparison) linked MPR viewports, crosshair, │ │ zoom / pan / W-L interaction, maximize, per-view caches -│ ├── Floating windows: 3D structures (both datasets through the registration, -│ │ per-dataset opacity, vector-field glyphs), planar image viewers -│ ├── Data tree operations: rename every level (patient, study, series, sets, -│ │ structures, segments, dose, plan, planar, REG, records); -│ │ Shift-click range selection; copy / move / remove / export the ticked -│ │ items from a button row or the context menu; -│ │ copy / move / remove patient · study · series across datasets; -│ │ create / connect / copy / move / remove RT structure sets and segmentation -│ │ series; copy / move / remove single or selected structures and segments -│ ├── Tool windows: auto-segmentation, prompt segmentation, slice propagation, -│ │ model manager, structure propagation, DRR, export, anonymizer, -│ │ test-data generator (one shared skeleton) +│ ├── Tool windows (one shared skeleton; each can be docked over the views or +│ │ detached into its own window of the operating system): +│ │ 3D structures, planar viewers, auto-segmentation, prompt segmentation, +│ │ slice propagation, body contour, structure algebra, structure propagation, +│ │ 4D motion / ITV and its results, structure comparison, transfer by +│ │ relationship, DVH, DRR, PACS, model manager, export, anonymizer, generator +│ ├── Data tree operations: rename every level; Shift-click ranges; copy / move / +│ │ remove / export the ticked items; create / connect / copy / move / remove +│ │ structure sets and segmentation series; move single structures / segments │ ├── Background jobs: one progress handle, one poll loop -│ ├── Settings: theme, model folder (viewer_settings.txt) +│ ├── Settings: theme, model folder, archive folder, optional modules, +│ │ detached windows (viewer_settings.txt in the config folder) │ └── Theme: dark / light / system, accent colors │ ├── DICOM -│ ├── Import: directory scan, classification, patient ▶ study ▶ series tree, dataset merging -│ │ ├── Volumes: CT, MR, PT, NM, US, OT (parallel decode, compressed syntaxes, geometry) +│ ├── Import: directory scan, classification, patient ▶ study ▶ series tree, merging +│ │ ├── Volumes: CT, MR, PT, NM, US, OT (parallel decode, compressed syntaxes) │ │ └── Planar images: DX, CR, RTIMAGE, MG, XA, RF, PX -│ ├── RT objects -│ │ ├── RTSTRUCT (structure sets, contours, reference chain) -│ │ ├── SEG (DICOM Segmentation: binary / fractional multi-frame masks, -│ │ │ frame-position lattice, CIELab colors, read and written) -│ │ ├── RTDOSE (grids, trilinear patient-space sampling, plan reference) -│ │ ├── RTPLAN / RT Ion Plan (beams, control points, prescriptions) -│ │ ├── RTIMAGE (DRR / portal, as planar image) -│ │ ├── REG (spatial registration matrices and deformable grids, applied as -│ │ │ the active registration; a recovered field written back out) -│ │ └── RT (Ion) Beams Treatment Record (delivered metersets) -│ ├── Export: CT series + RTSTRUCT + SEG + RTDOSE + RTPLAN with an editable tag table -│ └── Anonymizer: scan, review every identifying tag, rewrite with consistent UID remap +│ ├── RT objects: RTSTRUCT, SEG (binary / fractional, read and written), RTDOSE, +│ │ RTPLAN / RT Ion Plan, RTIMAGE, REG (matrices and deformable grids, applied +│ │ as the active registration, written back out), RT (Ion) Treatment Record +│ ├── Export: CT + RTSTRUCT + SEG + RTDOSE + RTPLAN with an editable tag table +│ ├── Anonymizer: scan, review every identifying tag, rewrite with a UID remap +│ └── Patient archive: a local store filed patient ▶ study ▶ instance with text +│ sidecars; import with dedupe, listing without opening a file, loading into +│ a dataset, derived objects (RTSTRUCT, SEG) sent back under the original UIDs │ ├── Data simulation │ ├── Synthetic RT phantom study (CT, RTSTRUCT, RTDOSE, RTPLAN, DX, RTIMAGE, REG, RTRECORD) │ ├── Known-transform study generator (rigid + Gaussian deformation, registration QA) -│ └── Digitally reconstructed radiographs: exact Siddon ray tracing (plastimatch) -│ and interpolating ray-casting (ITK), IEC cone-beam geometry, beam's-eye view -│ from an RTPLAN beam, side-by-side difference, filed into the data tree -│ as planar RT images with the geometry that produced them +│ └── DRR: exact Siddon tracing (plastimatch) and interpolating ray-casting (ITK), +│ IEC cone-beam geometry, beam's-eye view from an RTPLAN beam, difference image │ ├── Image registration │ ├── elastix-style rigid (6-DOF Euler, ASGD, pyramids, stochastic sampling) │ ├── elastix-style deformable (rigid pre-alignment + cubic B-spline FFD) -│ ├── plastimatch-style deformable (align_center, dense analytic gradient, -│ │ bending-energy regularization, L-BFGS, mean squares or Mattes mutual information) +│ ├── plastimatch-style deformable (dense analytic gradient, bending energy, +│ │ L-BFGS, mean squares or Mattes mutual information) │ ├── plastimatch-style landmark warp (thin-plate spline, Gaussian, Wendland) │ ├── Local registration: any method restricted to a structure with a margin; │ │ refinement composed on top of an existing result │ ├── Analytics: 6-DOF Procrustes fit, displacement statistics, Jacobian │ │ determinant and folding, per-structure displacement -│ ├── Vector field: lattice sampling, arrows / deformed grid in the MPR views, -│ │ 3-D glyphs, both datasets in one 3-D scene with per-dataset opacity -│ ├── Fusion overlay (magenta / green blend on the fixed dataset) -│ └── DICOM REG matrices and Deformable Spatial Registration grids applied as the -│ active registration; the recovered field written back out as one +│ ├── Vector field: arrows / deformed grid in the views, 3-D glyphs +│ ├── Fusion overlay (magenta / green) +│ └── Structure propagation across any registration, globally or refined on an +│ enclosing structure first +│ +├── 4D and motion +│ ├── 4D groups: phases recognised from descriptions and temporal identifiers, +│ │ AVG / MIP filed with them, hand-built groups kept across re-detection +│ ├── Motion pipeline per phase: register (rigid / deformable) ▸ propagate the +│ │ targets ▸ centroid, volume, peak-to-peak, drift, correlation with a +│ │ reference structure (Pearson r, p), registration QA +│ ├── ITV: union over phases with a margin, landed as a segmentation +│ ├── Results window: charts, tables, CSV, run-vs-run (A/B) comparison +│ ├── Structure comparison: volumes, centroid offset, Dice, HD95, mean surface distance +│ └── Transfer by relationship: a structure placed in the other dataset at its +│ offset from a reference structure +│ +├── Dose analysis +│ └── DVH: dose sampled over the structure's own lattice, cumulative and +│ differential curves, Dx% / Dxcc / Vx metrics, protocol constraints, CSV │ ├── Segmentation -│ ├── Body / EXTERNAL contour: thresholding by modality (HU, or MR after bias -│ │ flattening), spacing-aware opening, extruded-equipment removal along all -│ │ three axes, component selection by volume, thin-anatomy recovery, -│ │ slice-wise filling — classically, or guided by TotalSegmentator's body -│ │ network (CT 6 / 1.5 mm, MR) │ ├── Voxel masks: brush / eraser (2D, 3D), geodesic region growing, undo, -│ │ slice overlays, hole filling, mask ▶ RTSTRUCT contours, RTSTRUCT ▶ mask, -│ │ grouped into segmentation series that live in the study and bind to an -│ │ image series (resampled onto its lattice when it is displayed) -│ ├── Propagation: structures and segmentations carried across a registration, -│ │ globally or refined on an enclosing structure first +│ │ overlays, hole filling, mask ⇄ RTSTRUCT, segmentation series bound to an +│ │ image series (resampled onto its lattice for display) +│ ├── Body / EXTERNAL contour: threshold by modality (HU, or bias-flattened MR), +│ │ spacing-aware opening, extruded-equipment removal along all three axes, +│ │ component selection, thin-anatomy recovery — classically, or guided by +│ │ TotalSegmentator's body network +│ ├── Structure algebra: union / intersection / subtraction / symmetric difference, +│ │ a margin per operand and on the result (six patient directions, exact +│ │ ellipsoids), crop, fill / smooth / prune │ ├── Surfaces: contour and mask ▶ meshes (scanline fill, surface nets, smoothing) -│ ├── Auto-segmentation — TotalSegmentator (nnU-Net), 117 classes, -│ │ 3 mm / 1.5 mm × 5 / 6 mm models, CPU (im2col + SIMD GEMM) or GPU (burn/wgpu) -│ ├── Prompt segmentation — SegVol, box / point / text prompts, -│ │ 3-D ViT + SAM-style decoder + CLIP text tower, zoom-out / zoom-in passes -│ └── Slice propagation — MedSAM2 (SAM 2.1 Hiera-T), box drawn in the view, -│ preview / include-exclude refinement, memory-bank propagation through the stack +│ ├── Auto-segmentation — TotalSegmentator (nnU-Net), 117 classes, 3 / 1.5 / 6 mm +│ │ models, CPU (im2col + SIMD GEMM) or GPU (burn / wgpu) +│ ├── Prompt segmentation — SegVol: box / point / text, 3-D ViT + SAM-style +│ │ decoder + CLIP text tower, zoom-out / zoom-in passes +│ └── Slice propagation — MedSAM2 (SAM 2.1 Hiera-T): box drawn in the view, +│ include / exclude refinement, memory-bank propagation through the stack │ ├── Neural-network infrastructure (shared by every engine) -│ ├── Model folder: /models/{totalsegmentator, segvol, medsam2}, legacy migration -│ ├── Model manager: one inventory of every downloadable model, its state and size; -│ │ download / update / remove one or all, free redundant source checkpoints +│ ├── Model folder: /models/{totalsegmentator, segvol, medsam2}, +│ │ legacy migration; the model manager's inventory (state, size, download / +│ │ update / remove / free) │ ├── Weights: download (rustls), torch pickle reader, safetensors cache, conversion -│ ├── Device: Auto / GPU / CPU preference, one validated wgpu context, panic guard +│ ├── Device: Auto / GPU / CPU, one validated wgpu context, panic guard │ ├── Parameters: shape-checked view of a state dict -│ └── CPU kernels: Mat / Act tensors, gemm linear, layer norm, activations, attention, -│ transposed conv, f16 ↔ f32 +│ └── CPU kernels: Mat / Act tensors, gemm linear, layer norm, activations, +│ attention, transposed conv, f16 ↔ f32 │ ├── Core services │ ├── Volume: patient-space geometry (LPS), slice extraction, sampling, canonical axes │ ├── Geometry: Vec3 math, direction labels +│ ├── Morphology: exact anisotropic distance transform, erode / dilate / open / +│ │ close, ellipsoidal margins, components, hole filling │ ├── Render: window / level, dose colorwash, marching-squares isodose, contour ∩ plane │ └── Progress: message, fraction, device, cancel, phase window │ -├── Tests: 9 integration suites + in-module unit tests, synthetic phantom, reference dumps -├── Examples: headless CLIs and probes for the three engines (shared examples/common) -├── Tools: Python scripts that produce the reference fixtures (never needed at runtime) +├── Tests: 14 integration suites + in-module unit tests, synthetic phantom, reference dumps +├── Examples: headless CLIs and probes for the engines (shared examples/common) +├── Tools: the two PyTorch scripts that produce the MedSAM2 reference fixtures ├── Installer: Windows setup (shortcuts, VC++ runtime, optional weight prefetch, uninstall) -└── CI: fmt, clippy -D warnings, tests on Linux + Windows, CPU-only build, installer build +└── CI: fmt, clippy -D warnings, tests on Linux + Windows, CPU-only build; every push + to main builds the installer and a Linux AppImage into a GitHub release ``` ### Sources of the algorithms -Nothing in the tree above is bound as a library; each of the heavy -algorithms is a native re-implementation of a published reference, and the -reference is what the tests compare against. Registration follows -[elastix](https://elastix.dev/) (rigid and B-spline, ASGD, pyramids) and +Nothing above is bound as a library; each heavy algorithm is a native +re-implementation of a published reference, and the reference is what the +tests compare against. Registration follows [elastix](https://elastix.dev/) +(rigid and B-spline, ASGD, pyramids) and [plastimatch](https://plastimatch.org/) (dense B-spline with L-BFGS and a -bending-energy penalty, and the `landmark_warp` radial-basis kernels); the -mutual-information metric follows Mattes et al. (IEEE TMI 2003). The two DRR -projectors follow plastimatch's exact Siddon tracer and ITK's +bending-energy penalty, and the `landmark_warp` kernels); mutual information +follows Mattes et al. (IEEE TMI 2003). The DRR projectors follow +plastimatch's exact Siddon tracer and ITK's `RayCastInterpolateImageFunction`. Auto-segmentation re-implements [TotalSegmentator](https://github.com/wasserth/TotalSegmentator) on its [nnU-Net](https://github.com/MIC-DKFZ/nnUNet) models; prompt segmentation re-implements [SegVol](https://github.com/BAAI-DCAI/SegVol); slice propagation re-implements [MedSAM2](https://github.com/bowang-lab/MedSAM2), i.e. Meta's [SAM 2](https://github.com/facebookresearch/sam2) fine-tuned on -medical images. The papers to cite, the licences of the weights and the -numerical validation of each port are in the per-feature documents -([registration.md](registration.md), [auto-segmentation.md](auto-segmentation.md), -[segvol.md](segvol.md), [medsam2.md](medsam2.md)). +medical images. Papers, weight licences and the numerical validation of each +port are in the per-feature documents ([registration.md](registration.md), +[auto-segmentation.md](auto-segmentation.md), [segvol.md](segvol.md), +[medsam2.md](medsam2.md)). ## Module map -Where each function above lives. The right-hand tag names its functional -category (**App**, **DICOM**, **Sim**, **Reg**, **Seg**, **NN**, **Core**). +Where each function lives. The right-hand tag is the functional category +(**App**, **DICOM**, **Sim**, **Reg**, **4D**, **Dose**, **Seg**, **NN**, +**Core**). ``` src/ @@ -181,48 +184,58 @@ src/ tests and the examples drive the same code as the GUI progress.rs the one progress handle + ProgressSink, Quiet, Stderr Core models.rs the model folder: root, per-engine sub-folders, migration, - and the inventory of every downloadable model (state, size, - download / update / remove / free) NN - settings.rs persisted preferences (theme, model folder) App + the inventory of every downloadable model NN + settings.rs persisted preferences and the config / data folders App + archive.rs the local patient archive: on-disk layout, sidecars, + scanning, importing, index rebuild, removal DICOM app/ egui application, split by concern; every submodule is a - further `impl ViewerApp` block, so the struct and all its - state stay in one place while the behaviour is grouped: App + further `impl ViewerApp` block, so the struct and its state + stay in one place while the behaviour is grouped: App mod.rs ViewerApp and every type it holds, construction, the job plumbing (Job::spawn, poll_job, poll_tool_job), per-frame driver theme.rs theme-dependent colors chrome.rs menu bar, toolbar, status bar, help - panels.rs left panel: its show / hide, the optional modules and - the per-dataset Data tree sections + detach.rs every tool window, docked in the main window or in its + own window of the operating system + panels.rs left panel: show / hide, the per-dataset Data tree sections + reg_panel.rs the Image registration section: method, region, parameters, + landmarks, the run, the analytics, the vector field views.rs central MPR viewports, interaction, texture caches d3.rs live 3D structure window planar.rs floating DX / CR / RTIMAGE viewers tree.rs dataset-tree copy / move / remove with reference chains - rename.rs renaming every level of the data tree: the targets, the - one-field dialog, and the study-only rename itself + rename.rs renaming every level of the data tree sets.rs structure sets and segmentation series as tree nodes: - create, connect to an image series, copy / move / remove - whole series, and move single structures / segments - between any two of them (contour ⇄ mask conversion) + create, connect, copy / move / remove, move single + structures / segments (contour ⇄ mask conversion) jobs.rs loading, simulation, export, generator, anonymizer and auto-segmentation job starts dialogs.rs auto-segmentation window + results, generator, anonymizer, export, error dialog - reg_panel.rs the Image registration section: method, region, parameters, - landmarks, the run, the analytics, the vector field - models_win.rs the model manager window - propagate_win.rs structure propagation window and worker - drr_win.rs the DRR window: geometry, projectors, comparison seg.rs interactive segmentation state machine, mask ▶ RTSTRUCT, landing an auto-segmentation result - body_win.rs the body-contour window: method choice, the modality's - own threshold row, the classical / model-assisted split - seg_engines.rs what the four tool windows share: names and glyphs, - device / model-folder / licence / progress rows, - result landing, the "still the same dataset" check + seg_engines.rs what the tool windows share: names and glyphs, device / + model-folder / licence / progress rows, result landing, + the "still the same dataset" check + body_win.rs the body-contour window + combine_win.rs the structure-algebra window: operands, margins, the recipe prompt_seg.rs prompt segmentation window and worker (SegVol) box_seg.rs slice propagation: the box drawn in the viewport, the preview / refine / propagate loop, the resident session (MedSAM2) + propagate_win.rs structure propagation window and worker + motion_win.rs the 4D motion / ITV window and its per-phase pipeline + worker (register ▸ propagate ▸ measure ▸ ITV) + motion_results.rs the motion results window: charts, tables, correlations, + QA, CSV, run-vs-run comparison + compare_win.rs compare structures: volumes, centroid offset, Dice, HD95, MSD + transfer_win.rs transfer by relationship + dvh_win.rs the DVH window: pickers, the plot, the metrics table, + constraints, export + drr_win.rs the DRR window: geometry, projectors, comparison + pacs_win.rs the PACS window: archive root, patient / study list, + import, load, send back + models_win.rs the model manager window loader.rs directory scan, classification, parallel volume loading, dataset merging, safe DICOM element helpers DICOM @@ -231,23 +244,25 @@ src/ geometry.rs minimal 3D vector math (Vec3, f64, patient mm) Core render.rs window / level, dose colorwash, marching-squares isodose, contour / plane intersection Core + morphology.rs binary-mask geometry in millimetres: exact anisotropic + distance transform, erode / dilate / open / close, + ellipsoidal margins, components, hole filling, the + extruded-equipment test, box-blur smoothing Core rtstruct.rs RT Structure Set parsing DICOM dicomseg.rs DICOM Segmentation: the segmentation-series model, SEG - reading (binary / fractional, frame-position lattice), - resampling between lattices, the SEG writer DICOM + reading, resampling between lattices, the SEG writer DICOM rtdose.rs RT Dose parsing + trilinear patient-space sampling DICOM rtplan.rs RT Plan / RT Ion Plan parsing DICOM - extras.rs DX / CR / RTIMAGE planar images, REG (matrices and - deformation grids), RTRECORD DICOM - dicom_export.rs DICOM writer (CT series, RTSTRUCT, SEG, RTDOSE, RTPLAN, and - the Deformable Spatial Registration a recovered field - becomes) DICOM + extras.rs DX / CR / RTIMAGE planar images, REG, RTRECORD DICOM + dicom_export.rs DICOM writer (CT, RTSTRUCT, SEG, RTDOSE, RTPLAN, Deformable + Spatial Registration) DICOM anonymize.rs interactive DICOM anonymizer engine DICOM gen_test_data.rs synthetic RT phantom study generator Sim simulate.rs known-transform study generator (registration QA) Sim + drr.rs DRR: IEC cone-beam geometry, Siddon exact tracing and + ITK-style interpolating ray-casting Sim registration.rs parameters, transforms (rigid, B-spline, RBF, field, - composite), region masks, the image pyramid and the - samplers, and the engine dispatch Reg + composite), region masks, pyramid, samplers, dispatch Reg elastix.rs stochastic sampling + ASGD, rigid and B-spline stages plastimatch.rs align_center, dense analytic gradient, bending energy, Mattes mutual information, L-BFGS @@ -256,22 +271,21 @@ src/ dvf.rs vector-field sampling and its view-plane / 3-D glyphs propagate.rs structures across a registration: pull-back with a cached mapping lattice Reg - drr.rs digitally reconstructed radiographs: IEC cone-beam geometry, - Siddon exact tracing and ITK-style interpolating ray-casting Sim + fourd.rs 4D sub-studies: phase recognition, ordered groups + (phases + AVG / MIP), custom-group rules 4D + motion.rs motion arithmetic over phases: centroids, peak-to-peak, + drift, Pearson r with p-values, Dice / HD95 / MSD overlap, + ITV unions, the motion report + CSV 4D + dvh.rs dose–volume histograms: sampling, curves, metrics, + protocol constraints, CSV Dose segmentation.rs voxel masks: brush, geodesic grow, undo, overlays, - label map ▶ segmentations, mask ▶ RTSTRUCT contours and - RTSTRUCT contours ▶ mask Seg - morphology.rs binary-mask geometry, in millimetres: the exact - anisotropic Euclidean distance transform and the - erode / dilate / open / close it powers, 6-connected - components, slice-wise and 3-D hole filling, the - extruded-equipment test, box-blur smoothing Core - bodymask.rs the body / EXTERNAL contour: foreground by modality (HU, - or bias-flattened MR), equipment removal, component - selection, thin-anatomy recovery, filling — classically - or guided by the body network Seg - mesh3d.rs contour / mask ▶ surface meshes (scanline fill, - surface nets, Laplacian smoothing) Seg + label map ▶ segmentations, mask ⇄ RTSTRUCT contours Seg + structops.rs structure algebra: the four boolean operations, margins, + crop, fill / smooth / prune, over masks on one lattice Seg + bodymask.rs the body / EXTERNAL contour, classically or guided by the + body network Seg + mesh3d.rs contour / mask ▶ surface meshes (scanline fill, surface + nets, Laplacian smoothing) Seg nn/ shared neural-network infrastructure — nothing in here knows about a particular architecture NN @@ -283,15 +297,12 @@ src/ params.rs shape-checked view of a loaded state dict half.rs binary16 ↔ binary32 conversion tensor.rs Mat [rows, cols] and Act [c, d, h, w]; transposed conv - (a hand-tuned 2× and a general kernel = stride form) - linalg.rs gemm-backed linear / matmul, layer norm, softmax, - GELU / ReLU / QuickGELU + linalg.rs gemm-backed linear / matmul, layer norm, softmax, activations attention.rs multi-head attention, optionally causally masked autoseg/ automatic segmentation (pure-Rust TotalSegmentator) Seg - mod.rs public API: variants, run(), run_specs() (the engine - minus the question, shared with the body contour), - progress phases + mod.rs public API: variants, run(), run_specs() (shared with the + body contour), progress phases classes.rs 117-class table, sub-model maps, organ colors config.rs nnU-Net plans.json parsing weights.rs which models exist, where they are published, the @@ -302,9 +313,7 @@ src/ preprocess.rs resampling to the model grid and back (scipy conventions) infer.rs Gaussian sliding window, streaming argmax - segvol/ prompt segmentation (pure-Rust SegVol) — box, point and - text prompts, for the structures a fixed-class model - cannot cover Seg + segvol/ prompt segmentation (pure-Rust SegVol) Seg weights.rs the checkpoint and tokenizer files, load(), licensing notes layout.rs the published checkpoint's tensor layout and its checks config.rs the network's fixed dimensions @@ -319,9 +328,9 @@ src/ clip.rs CLIP text tower + dim_align, with a prompt cache gpu.rs image encoder on wgpu via burn (cargo feature `gpu`) - medsam2/ slice propagation (pure-Rust MedSAM2 — SAM 2.1 fine-tuned - on medical images); every module is generic over a `burn` - backend, so one implementation runs on GPU and CPU Seg + medsam2/ slice propagation (pure-Rust MedSAM2); every module is + generic over a `burn` backend, so one implementation runs + on GPU and CPU Seg weights.rs the four published variants, load(), the research-only licence layout.rs the checkpoint's tensor layout and its checks config.rs the fixed dimensions: 512 input, 7 memories, 16 pointers @@ -345,220 +354,178 @@ src/ engine.rs backend choice, the encoded-slice cache, the one call the user interface makes -tests/ nine integration suites (see Testing) -examples/ autoseg_cli, autoseg_probe, segvol_cli, segvol_probe, +tests/ fourteen integration suites (see Testing) +examples/ autoseg_cli, autoseg_probe, body_cli, segvol_cli, segvol_probe, medsam2_cli, medsam2_probe; common/ holds what they share -tools/ gen_reference_activations.py, gen_ops_fixtures.py — the - two PyTorch scripts that produce the fixtures and reference - dumps the MedSAM2 tests compare against (never run at - build time; needed only to regenerate them) -installer/ the Windows installer, its own workspace (see its README) +tools/ gen_reference_activations.py, gen_ops_fixtures.py — the two + PyTorch scripts that produce the fixtures and reference dumps + the MedSAM2 tests compare against (never run at build time) +installer/ the Windows installer, its own workspace (see its README); + built by the release workflow ``` ## UI architecture -`ViewerApp` is defined in `app/mod.rs` together with every type it holds; -the sibling modules only add `impl ViewerApp` blocks. Keeping the -definitions in the parent module is what lets each child reach the struct's -private fields without widening any visibility beyond `pub(super)`. +`ViewerApp` is defined in `app/mod.rs` together with every type it holds; the +sibling modules only add `impl ViewerApp` blocks, so each child reaches the +struct's private fields without widening any visibility beyond `pub(super)`. `ViewerApp` owns two `StudySlot`s (datasets A and B). Each slot holds the -loaded study (series list, the volume behind an `Arc`, structure sets, -doses, plans, planar images, registrations, records), three `ViewState`s -(per-plane slice, zoom/pan, and all texture caches), the crosshair, per-ROI -visibility, and the segmentation masks. Global state covers window/level, -dose display settings, tool selection, the registration result, the model -folder, and the theme. - -Rendering is cache-driven: each view keeps keyed textures for the -grayscale slice, dose colorwash, contour polylines, segmentation overlay -and fusion blend, rebuilt only when their inputs change (slice, W/L, dose -settings, ROI visibility, mask edits, registration). Invalidation uses -small generation counters bumped by the owning mutation sites — and only by -those: a ROI visibility toggle, for instance, is part of the contour key -alone and leaves the dose and fusion textures untouched. Repaints are -demand-driven; while background jobs run, the UI polls at 10 Hz. - -### The segmentation tool windows - -Body contouring, auto-segmentation, prompt segmentation and slice -propagation are different conversations — a parameterised geometric run, a -batch run with a result-selection dialog, a one-shot prompt, an interactive -box loop — but they are the same kind of tool, and `app/seg_engines.rs` -makes them look and behave alike: - -* one `ToolInfo` per tool gives the glyph (👤 🤖 🧠 ⏩), the window title - (`🤖 Auto-segmentation — dataset A`, the same pattern as - `3D structures — dataset A`), the menu entry (`🤖 Auto-segment dataset A…`) - and the small sidebar button (`🤖 Auto…`); -* every window is floating, collapsible and closable, and stays open while - its run is in flight — the button row becomes the progress row (device, - bar, message, Cancel); closing it never stops the run, and the sidebar - *Segmentations* section shows whichever engine is running on that - dataset with the same Cancel; -* the sections come in the same order: a one-line description naming the - engine, the tool's own inputs, `Name`, a collapsed **Options** header - holding the engine's settings plus the shared `Compute: Auto / GPU / CPU` - and `Model folder` rows, one small licence line ("… Research / QA use — - not a medical device."), then `▶ Segment` / `▶ Propagate` / `▶ Contour` - and `Close`, and a status line summarising the last result; -* what a tool does not have, it does not show: the body contour's classical - method needs no device, no model and no download, so those rows appear - only when its method is the model-assisted one; -* results land the same way — `add_segmentation` with the next palette - colour — and a run that finishes after the dataset was replaced is - discarded with the same message. +loaded study (series, the volume behind an `Arc`, structure sets, doses, +plans, planar images, registrations, records, 4D groups), three `ViewState`s +(per-plane slice, zoom / pan, texture caches), the crosshair, per-ROI +visibility and the segmentation masks. Global state covers window / level, +dose display, tool selection, the registration result, the model folder and +the theme. + +Rendering is cache-driven: each view keeps keyed textures for the grayscale +slice, dose colorwash, contour polylines, segmentation overlay and fusion +blend, rebuilt only when their inputs change. Invalidation uses generation +counters bumped by the owning mutation sites — and only by those: a ROI +visibility toggle is part of the contour key alone and leaves the dose and +fusion textures untouched. Repaints are demand-driven; while background jobs +run, the UI polls at 10 Hz. + +### The tool windows + +Every secondary window is drawn through `app/detach.rs`: docked, it is an +`egui::Window` floating over the viewports; detached, the same closure draws +into a native window of the operating system that can live on another +monitor. The choice is per window and persisted. + +The segmentation-type tools — body contour, structure algebra, +auto-segmentation, prompt segmentation, slice propagation, 4D motion — are +different conversations but the same kind of tool, and `app/seg_engines.rs` +makes them alike: one `ToolInfo` per tool gives the glyph, the window title +(`🤖 Auto-segmentation — dataset A`), the menu entry and the small sidebar +button; every window stays open while its run is in flight, the button row +becoming the progress row (device, bar, message, Cancel); the sections come +in the same order (description, the tool's inputs, `Name`, a collapsed +**Options** with the shared `Compute` and `Model folder` rows, the licence +line, `▶ Segment` / `▶ Propagate` / `▶ Contour`, `Close`, status); rows a +tool has no use for are not shown; and results land the same way +(`add_segmentation`), a run that finishes after its dataset was replaced +being discarded with the same message. ## Background jobs One pattern serves every long operation: ```rust -struct Job { progress: Arc

, rx: mpsc::Receiver } +struct Job { progress: Arc, rx: mpsc::Receiver } ``` `Job::spawn` snapshots the inputs, starts a `std::thread` and hands the worker the progress handle; the UI polls the channel each frame -(`poll_job`): a received value lands the result, a disconnect means the -worker died and surfaces as an error. The engines and the registration -answer with `(slot, Result)`, and `poll_tool_job` turns a failure into an -error dialog — except a cancellation, which is what the user asked for. - -There is one `Progress` type (`progress.rs`): a message, a fraction for -progress bars, the device label once known, an atomic cancel flag, and a -phase window that maps a sub-step's own 0‥1 onto its slice of the overall -bar. Workers see it through the `ProgressSink` trait, which the headless -examples implement on standard error and the tests with `Quiet`. Workers -use `rayon` internally for data parallelism; the thread-per-job is only -the container. - -Results are validated on landing where the underlying data could have -changed meanwhile (every engine checks volume dimensions and -frame-of-reference UID before applying). +(`poll_job`): a value lands the result, a disconnect means the worker died +and surfaces as an error. The tools answer with `(slot, Result)`, and +`poll_tool_job` turns a failure into an error dialog — except a +cancellation, which is what the user asked for. + +`Progress` (`progress.rs`) holds a message, a fraction, the device label, +an atomic cancel flag and a phase window that maps a sub-step's own 0‥1 onto +its slice of the overall bar. Workers see it through `ProgressSink`, which +the headless examples implement on standard error and the tests with +`Quiet`. Workers use `rayon` internally; the thread-per-job is only the +container. Results are validated on landing where the underlying data could +have changed meanwhile (volume dimensions, frame-of-reference UID). ## The model folder Every engine downloads its published checkpoint on first use and keeps it, -with the converted `safetensors` cache beside it, under one root that the -user can move from any of the three tool windows and that is persisted as -`models_dir` in `viewer_settings.txt`: +with the converted `safetensors` cache beside it, under one root. The default +is `models/` in the application's data folder +(`%LOCALAPPDATA%\RustDICOMStation` on Windows, +`~/.local/share/RustDICOMStation` on Linux, `~/Library/Application +Support/RustDICOMStation` on macOS); it can be moved from any tool window +and is persisted as `models_dir` in `viewer_settings.txt`, which lives in +the config folder (the same folder on Windows, `~/.config/RustDICOMStation` +on Linux): ``` -/models/ +/models/ totalsegmentator//model.safetensors + plans.json segvol/pytorch_model.bin, vocab.json, merges.txt, segvol.safetensors medsam2/MedSAM2_.pt + .safetensors ``` -`models.rs` owns the layout; `nn/cache.rs` owns the path from a URL to a -loaded tensor map (`RemoteFile::ensure` ▶ `convert_checkpoint` ▶ -`load_safetensors`, wrapped as `ensure_converted`), and each engine's -`weights.rs` only says which files, which tensors and under what names. -Installations that predate the single root are migrated at startup: the -old `autoseg_models/`, `segvol_model/` and `medsam2_model/` folders beside -the executable are renamed into place, never re-downloaded. The Windows -installer writes the same key and pre-fetches only the Apache-2.0 -TotalSegmentator weights, into `models/totalsegmentator/`. +`models.rs` owns the layout and the inventory behind the model manager; +`nn/cache.rs` owns the path from a URL to a loaded tensor map +(`RemoteFile::ensure` ▶ `convert_checkpoint` ▶ `load_safetensors`, wrapped as +`ensure_converted`); each engine's `weights.rs` only says which files, which +tensors and under what names. Installations that predate the single root are +migrated at startup: the old `autoseg_models/`, `segvol_model/` and +`medsam2_model/` folders beside the executable are renamed into place. The +Windows installer uses the same default, records `models_dir` only when a +different folder is chosen, and pre-fetches only the Apache-2.0 +TotalSegmentator weights. ## Geometry conventions -* Patient space is DICOM **LPS**, `f64` millimeters (`Vec3`). +* Patient space is DICOM **LPS**, `f64` millimetres (`Vec3`). * Volume voxels are stored `data[k·nx·ny + j·nx + i]` with dims `[nx, ny, nz]` = [columns, rows, slices]; `origin` is the **center** of - voxel (0,0,0); `row_dir`/`col_dir`/`normal` are unit vectors, so the + voxel (0,0,0); `row_dir` / `col_dir` / `normal` are unit vectors, so the code never assumes axis-aligned volumes. * `Volume::canonical_axes` finds the permutation and flips onto `[S, A, R]` - by direction cosine; all three engines orient through it (MedSAM2 reads - the in-plane axes the other way round, as SAM 2 does). + by direction cosine; all engines orient through it (MedSAM2 reads the + in-plane axes the other way round, as SAM 2 does). * Segmentation masks use the identical index order, so mask ↔ volume operations are index-parallel. -* Display convention: sagittal/coronal view rows run superior → inferior - (`y = (nz−1) − k`); every producer of view-space pixels honors the same +* Display: sagittal / coronal view rows run superior → inferior + (`y = (nz−1) − k`); every producer of view-space pixels honours the same flip (asserted by tests). -* Interpolation is trilinear unless stated. The engines deliberately keep - their reference implementations' resampling conventions — scipy `zoom` - (nnU-Net), PyTorch `nearest-exact` / `align_corners=false` (SegVol), PIL - antialiased bicubic in 8-bit fixed point (MedSAM2) — because each is - validated numerically against that reference; they are not unified. +* Interpolation is trilinear unless stated. The engines keep their reference + implementations' resampling conventions — scipy `zoom` (nnU-Net), PyTorch + `nearest-exact` / `align_corners=false` (SegVol), PIL antialiased bicubic + in 8-bit fixed point (MedSAM2) — because each is validated numerically + against that reference. ## Error handling and style -`anyhow::Result` with `bail!`/`context` at operation boundaries; missing -or malformed *individual* DICOM attributes never error — safe extraction -helpers return `Option` and per-file failures inside a batch become -warnings shown in the UI. Cancellation is an error whose message contains -`progress::CANCELLED`, recognised by the application. `rayon` idioms: -`par_iter` over independent files/ROIs, `par_chunks_mut` over image -rows/slices, dense per-chunk accumulators. Sums that decide a threshold or -a normalization stay sequential so a run reproduces itself. Modules open -with a `//!` block explaining the algorithm and its conventions, usually -citing the reference implementation (elastix, MITK, 3D Slicer, nnU-Net, -SAM 2). +`anyhow::Result` with `bail!` / `context` at operation boundaries; missing or +malformed *individual* DICOM attributes never error — safe extraction +helpers return `Option`, and per-file failures inside a batch become +warnings in the UI. Cancellation is an error whose message contains +`progress::CANCELLED`. `rayon` idioms: `par_iter` over independent files / +ROIs, `par_chunks_mut` over rows / slices; sums that decide a threshold or a +normalization stay sequential so a run reproduces itself. Modules open with +a `//!` block explaining the algorithm and its conventions, usually citing +the reference implementation. Because `lib.rs` makes every module public, `cargo clippy -D warnings` -cannot see an unused `pub` item; the 2026-08 review found them with a +cannot see an unused `pub` item; the periodic review finds them with a mechanical scan (every `pub` item referenced nowhere outside its own -tests) — worth repeating occasionally. +tests). ## Dependencies -Runtime dependencies are all pure Rust: `dicom-rs` (DICOM, with -`dicom-pixeldata` for decoding), `egui`/`eframe` (UI over wgpu), `rayon`, -`rfd` (file dialogs), `walkdir`, `anyhow`; for the engines additionally -`gemm` (SIMD matrix kernels), `serde_json` (plans.json, vocab.json), `zip`, -`ureq` (rustls + OS trust store), `safetensors`, and `burn` — always -compiled with its `ndarray` CPU backend (the MedSAM2 engine is written -against it), with the wgpu backend added by the cargo feature `gpu` -(default on). +All pure Rust: `dicom-rs` (DICOM, with `dicom-pixeldata` for decoding), +`egui` / `eframe` (UI over wgpu), `rayon`, `rfd` (file dialogs), `walkdir`, +`anyhow`; for the engines `gemm` (SIMD matrix kernels), `serde_json`, `zip`, +`ureq` (rustls + OS trust store), `safetensors`, and `burn` — always with +its `ndarray` CPU backend, with the wgpu backend added by the cargo feature +`gpu` (default on). ## Testing -Nine integration suites plus in-module unit tests run against the same -code paths the GUI uses, with no external data or tooling: - -* **synthetic_study** — generate the analytic phantom, reload, verify - geometry round-trips, HU values, contour radii, trilinear dose values, - isodose radii and plan fields against closed-form expectations; -* **simulate_export** — simulate a known transform → export DICOM → - reload → verify within format tolerances; -* **registration** — rigid and B-spline recovery of analytically known - transforms (sub-voxel assertions); -* **segmentation** — brush/undo semantics, geodesic-grow no-leak, - hole filling, one-pass label-map splitting, mask → RTSTRUCT contours, - meshing; -* **anonymize** — anonymize → reload: identity gone, references intact, - pixels byte-identical; -* **autoseg** — miniature network assembly with exact checkpoint naming + - forward pass; sliding-window steps and resampling conventions pinned to - nnU-Net/scipy reference values; an `#[ignore]`d end-to-end test against - the real 3 mm model; -* **segvol** — CPU/GPU agreement for the image encoder is `#[ignore]`d, not - because it is unimportant but because `WgpuDevice::default()` returns a - *software* adapter on CI runners; run it where the hardware is. The - published checkpoint's 475-tensor inventory is recorded in - `tests/data/segvol-tensors.csv` and asserted module by module. The same - fixture synthesizes a checkpoint with the real key names and shapes, so - the network assembles and runs a genuine forward pass in CI without the - 724 MB download; `#[ignore]`d tests cover the real file and the full - 181 M-parameter image-encoder pass. -* **medsam2** — the same synthesized-checkpoint trick assembles the real - 471-tensor network and runs genuine forward passes in CI: a slice through - the engine with the documented shapes, a box prompt propagated through a - small stack, an existing contour as the prompt, and the one-slice preview - agreeing with the propagation's first step while proving the encoded - slice is reused; -* **reference** — bit-level parity with the Python implementation. A - randomly initialized SAM 2.1-T is built with `sam2` and PyTorch by - `tools/gen_reference_activations.py`, which dumps every module's inputs - and outputs *and* a ten-slice run of SAM 2's own video predictor; the - suite reproduces all of it (worst 5.4e-6 relative). It skips when the - dump is absent, so CI stays self-contained: - `MEDSAM2_REF=/tmp/ref cargo test --release --test reference`. - -Beyond the automated tests, the auto-segmentation implementation was -validated against the reference implementation directly — exact -patch-level logit equivalence and mean Dice 0.9995 end-to-end (details in -[auto-segmentation.md](auto-segmentation.md#validation)). +Fourteen integration suites plus in-module unit tests run against the same +code paths the GUI uses, with no external data or tooling: the analytic +phantom round trip (**synthetic_study**), simulate → export → reload +(**simulate_export**), rigid and B-spline recovery of known transforms +(**registration**), masks, growing, contours and meshing (**segmentation**), +anonymize → reload (**anonymize**), SEG written and read back voxel for voxel +(**dicomseg**), the body contour on phantoms with couch, chair and mask +(**body**), the archive round trip (**archive**), the DVH against an analytic +Gaussian phantom (**dvh**), structure algebra (**structops**), and the three +engines assembled and run without a download — a miniature nnU-Net with the +exact checkpoint naming (**autoseg**), and synthesized checkpoints with the +real key names and shapes for **segvol** and **medsam2**, so genuine forward +passes run in CI. **reference** asserts bit-level parity of the +MedSAM2 port with the Python implementation (worst 5.4e-6 relative) from a +dump made by `tools/gen_reference_activations.py`, and skips when the dump +is absent: `MEDSAM2_REF=/tmp/ref cargo test --release --test reference`. +End-to-end runs against the real weights are `#[ignore]`d. ``` cargo test --release diff --git a/docs/auto-segmentation.md b/docs/auto-segmentation.md index 2a47e59..7613b55 100644 --- a/docs/auto-segmentation.md +++ b/docs/auto-segmentation.md @@ -1,12 +1,12 @@ # Automatic multi-organ segmentation -A complete, **pure-Rust re-implementation of +A **pure-Rust re-implementation of [TotalSegmentator](https://github.com/wasserth/TotalSegmentator) v2 CT -inference** (Wasserthal et al., *Radiology: Artificial Intelligence* 2023). -No Python, no ONNX runtime, no vendor toolkits — at build time or at run -time. The viewer segments a CT into up to **117 anatomical structures** -using the official nnU-Net models of TotalSegmentator's openly licensed -"total" task, running locally on the CPU or on any GPU via wgpu. +inference** (Wasserthal et al., *Radiology: Artificial Intelligence* 2023): +no Python, ONNX runtime or vendor toolkits, at build or run time. It +segments a CT into up to **117 anatomical structures** with the official +nnU-Net models of TotalSegmentator's openly licensed "total" task, locally +on the CPU or on any GPU via wgpu. ## Models @@ -17,11 +17,10 @@ using the official nnU-Net models of TotalSegmentator's openly licensed | **6 mm (preview)** | 298 | all 117 in one model | ≈ 135 MB | coarse but very fast | All variants are nnU-Net v2 `PlainConvUNet` 3D networks (5 stages at -3/6 mm, 6 at 1.5 mm): blocks of Conv3d → InstanceNorm → LeakyReLU with -strided-conv downsampling, a transposed-conv decoder with skip -connections, and a 1×1×1 segmentation head. The architecture is rebuilt at -load time from each model's `plans.json`, so the code carries no -hard-coded network definition. +3/6 mm, 6 at 1.5 mm): Conv3d → InstanceNorm → LeakyReLU blocks, +strided-conv downsampling, a transposed-conv decoder with skip connections, +and a 1×1×1 segmentation head — rebuilt at load time from each model's +`plans.json`, not hard-coded. ## Using it in the viewer @@ -30,128 +29,121 @@ sidebar *Segmentations* section opens the tool window (**🤖 Auto-segmentation — dataset A**; the three segmentation engines share one window layout, see [architecture.md](architecture.md#the-three-engine-windows)): -* **Model** — one of the three variants (the dialog shows whether weights - are already cached or how much will be downloaded once). For 1.5 mm, the - five sub-models can be toggled individually — running only *organs* + - *cardiac*, for instance, takes a fifth of the time of the full set. +* **Model** — one of the three variants; the dialog shows whether weights + are cached or how much will be downloaded once. For 1.5 mm the five + sub-models can be toggled individually — *organs* + *cardiac* alone takes + a fifth of the full set's time. * **Options ▸ Compute** — *Auto* (GPU when available, else CPU), *GPU*, or *CPU*. -* **Options ▸ Model folder** — the root every engine downloads into, - `models/` next to the executable by default; this engine's files go to - `models/totalsegmentator/`. Persisted as `models_dir` in - `viewer_settings.txt`; the same field appears in the other two tool - windows. - -**▶ Segment** runs on a background thread; the window's buttons turn into a -progress row (device, bar, message, **Cancel** — effective during download, -conversion and between inference tiles), and the sidebar shows the same -progress. When it finishes, a **results dialog** lists every detected structure with its -volume; the checked ones are materialized as ordinary editable -segmentations — brush/erase/grow correction, live 3D view, per-structure -colors from a curated anatomical palette — and can optionally be converted -to **RTSTRUCT contours** in the same step, after which they render like any -ROI and ride the DICOM export. Materializing only what you need matters on -large studies: every mask is a full-volume voxel map (≈ 35 MB at -512 × 512 × 133). - -If the dataset is switched or modified while a run is in flight, the -result is discarded with a message rather than applied to the wrong -volume. +* **Options ▸ Model folder** — the root every engine downloads into + (`%LOCALAPPDATA%\RustDICOMStation\models` on Windows, + `~/.local/share/RustDICOMStation/models` on Linux, by default); this + engine uses its `totalsegmentator/` sub-folder. Persisted as `models_dir` + in `viewer_settings.txt`. + +**▶ Segment** runs in the background; the buttons become a progress row +(device, bar, message, **Cancel** — effective during download, conversion +and between inference tiles), mirrored in the sidebar. A **results dialog** +then lists every detected structure with its volume; checked ones become +ordinary editable segmentations — brush/erase/grow correction, live 3D +view, per-structure colors from a curated anatomical palette — optionally +converted to **RTSTRUCT contours** in the same step, which then render like +any ROI and ride the DICOM export. Materialize only what you need: every +mask is a full-volume voxel map (≈ 35 MB at 512 × 512 × 133). + +If the dataset is switched or modified during a run, the result is +discarded with a message rather than applied to the wrong volume. ## Weight acquisition and caching On first use of a variant the viewer downloads the official weight zip(s) -from the TotalSegmentator GitHub release (TLS via rustls using the -**operating-system certificate store**, so corporate/clinical inspection -proxies with custom CAs work), then converts them natively: +from the TotalSegmentator GitHub release (TLS via rustls with the +**operating-system certificate store**) and converts them natively: 1. `plans.json` is extracted and validated; 2. `fold_0/checkpoint_final.pth` — a PyTorch zip/pickle checkpoint — is parsed by the built-in **torch-pickle reader** (`autoseg/pickle.rs`, a - minimal pickle virtual machine that understands `torch.save`'s - serialization: persistent storage IDs, `_rebuild_tensor_v2`, shapes, - strides and offsets); -3. the `network_weights` state dict is re-saved as - `model.safetensors` in the model folder. Duplicate tensor aliases in - the checkpoint (`*.all_modules.*`, `decoder.encoder.*`) are dropped. - -Subsequent runs load the cache directly with no network access. For -**air-gapped machines**, run any variant once on a connected machine and -copy the model folder (`models/totalsegmentator//` containing -`model.safetensors` + `plans.json`); the viewer never needs the network -again. Installations from before the single `models/` root are migrated at -startup: an old `autoseg_models/` beside the executable is renamed into -place, nothing is downloaded twice. + minimal pickle virtual machine covering `torch.save`'s persistent + storage IDs, `_rebuild_tensor_v2`, shapes, strides and offsets); +3. the `network_weights` state dict is re-saved as `model.safetensors` in + the model folder; duplicate tensor aliases (`*.all_modules.*`, + `decoder.encoder.*`) are dropped. + +Later runs load the cache with no network access. For **air-gapped +machines**, run any variant once on a connected machine and copy +`models/totalsegmentator//` (`model.safetensors` + `plans.json`). +Installations from before the single model folder are migrated at startup: +an old `autoseg_models/` beside the executable is renamed into place, +nothing is downloaded twice. ## The inference pipeline The pipeline mirrors TotalSegmentator exactly: -1. **Canonical orientation.** The volume's axes (from the DICOM direction - cosines) are permuted/flipped to the closest canonical [S, A, R] frame - — the axis order nnU-Net models were trained in. +1. **Canonical orientation.** Axes (from the DICOM direction cosines) are + permuted/flipped to the closest canonical [S, A, R] frame, the order + nnU-Net models were trained in. 2. **Resampling** to the model's isotropic spacing (1.5 / 3 / 6 mm), - trilinear, using the endpoint-aligned coordinate convention of - `scipy.ndimage.zoom` (the resampler TotalSegmentator uses), including - its int32 truncation. -3. **Normalization** per model: clip to the [0.5, 99.5] HU percentiles of - the training-set foreground, then z-score with the dataset-fingerprint - mean/std — all constants read from `plans.json`. + trilinear, with `scipy.ndimage.zoom`'s endpoint-aligned coordinate + convention (TotalSegmentator's resampler), including its int32 + truncation. +3. **Normalization** per model: clip to the training-set foreground's + [0.5, 99.5] HU percentiles, then z-score with the dataset-fingerprint + mean/std — all constants from `plans.json`. 4. **Sliding-window inference** with nnU-Net's exact tiling (step 0.8 × patch for the "total" task), Gaussian importance weighting - (σ = patch/8), zero-padded borders, and no mirroring test-time - augmentation (the "total" models are trained without it). The logit - accumulator is a **ring buffer along the leading patient axis**: rows - are finalized (argmax → label) as soon as no future tile can touch - them, so peak memory stays ≈ classes × patch-depth × slice-area floats - regardless of scan length — long whole-body scans stay bounded. + (σ = patch/8), zero-padded borders, no mirroring test-time augmentation + (the "total" models are trained without it). The logit accumulator is a + **ring buffer along the leading patient axis**: rows are finalized + (argmax → label) once no future tile can touch them, so peak memory + stays ≈ classes × patch-depth × slice-area floats regardless of scan + length. 5. **Label merging** (1.5 mm variant): each sub-model's local labels map onto the global 117-class ids; later sub-models overwrite earlier ones at overlaps, in TotalSegmentator's order. -6. **Back-mapping** to the original CT grid by nearest neighbor (order-0), - exactly as the reference implementation resamples its label map back. +6. **Back-mapping** to the original CT grid by nearest neighbor (order-0). ## Compute engines -**CPU** — a hand-written inference engine (`autoseg/cpu.rs`): 3D -convolution as per-output-slice im2col + pure-Rust SIMD GEMM (the `gemm` -crate), parallelized over slices with rayon; the transposed conv +**CPU** — a hand-written engine (`autoseg/cpu.rs`): 3D convolution as +per-output-slice im2col + pure-Rust SIMD GEMM (the `gemm` crate), +parallelized over slices with rayon; the transposed conv (kernel = stride = 2) is a GEMM plus disjoint scatter; instance norm and -LeakyReLU are fused. This is 15–50× faster than a direct convolution loop -— measured 25–100 GFLOP/s on modest hardware — which is what makes CPU -inference practical: a thorax CT with the 3 mm model takes well under a -minute on a desktop CPU (≈ 3.5 min even on a throttled 2-core VM). +LeakyReLU are fused. That is 15–50× faster than a direct convolution loop +(25–100 GFLOP/s measured on modest hardware): a thorax CT with the 3 mm +model takes well under a minute on a desktop CPU, ≈ 3.5 min even on a +throttled 2-core VM. -**GPU** — the same network runs through +**GPU** — the same network through [burn](https://github.com/tracel-ai/burn)'s **wgpu** backend (`autoseg/gpu.rs`): Vulkan / DX12 / Metal, i.e. NVIDIA, AMD, Intel and -Apple GPUs, with **no CUDA toolkit or vendor SDK** — kernels are generated -and autotuned at runtime. Weights are uploaded once per model; patches -stream through. *Auto* device selection probes for a usable adapter with a -self-test and falls back to the CPU. The GPU path is optional at build -time (`gpu` cargo feature, on by default; `--no-default-features` builds a -CPU-only viewer without the burn dependency tree). +Apple GPUs, with **no CUDA toolkit or vendor SDK**; kernels are generated +and autotuned at runtime. Weights upload once per model; patches stream +through. *Auto* probes for a usable adapter with a self-test and falls back +to the CPU. The GPU path is optional at build time (`gpu` cargo feature, on +by default; `--no-default-features` builds a CPU-only viewer without the +burn dependency tree). ## Validation -The implementation is verified against the reference at three levels: +Verified against the reference at three levels: -* **Network equivalence** — on an identical preprocessed input patch, the - Rust forward pass reproduces PyTorch/nnU-Net logits of the actual 3 mm - checkpoint to ≈ 1 × 10⁻⁴ absolute (float accumulation-order noise on - logits spanning ±86) with **100 % argmax agreement**. +* **Network equivalence** — on an identical preprocessed patch, the Rust + forward pass reproduces the actual 3 mm checkpoint's PyTorch/nnU-Net + logits to ≈ 1 × 10⁻⁴ absolute (float accumulation-order noise on logits + spanning ±86) with **100 % argmax agreement**. * **End-to-end** — on the bundled example study, the full pipeline agrees with the official Python TotalSegmentator at **mean Dice 0.9995 across - 90 detected structures** (worst structure 0.992, spleen 1.0000); - residual differences are single-voxel boundary tie-breaks. + 90 detected structures** (worst 0.992, spleen 1.0000); residual + differences are single-voxel boundary tie-breaks. * **CPU vs GPU** — the wgpu engine produced **bit-identical labels** to - the CPU engine over a full run (34.9 M voxels, zero differences, tested - on a software Vulkan implementation). + the CPU engine over a full run (34.9 M voxels, zero differences, on a + software Vulkan implementation). Unit tests pin the sliding-window step positions and resampling conventions to nnU-Net/scipy reference values; `tests/autoseg.rs` assembles a miniature network from synthetic tensors with the exact -checkpoint key naming and verifies the forward pass, and an `#[ignore]`d +checkpoint key naming and verifies the forward pass; an `#[ignore]`d end-to-end test runs the real 3 mm model against the bundled example data: ``` @@ -208,15 +200,15 @@ If you use the auto-segmentation in academic work, cite: ## Troubleshooting -* **"no usable wgpu adapter found"** — no Vulkan/DX12/Metal device is - available (headless machine, missing driver). *Auto* silently uses the - CPU; forcing *GPU* reports the error. +* **"no usable wgpu adapter found"** — no Vulkan/DX12/Metal device + (headless machine, missing driver). *Auto* silently uses the CPU; + forcing *GPU* reports the error. * **Download fails behind a proxy** — the downloader uses the OS trust - store, so an inspection proxy's CA installed system-wide is honored. For - fully offline machines, copy a converted model folder from another - machine (see above). + store, so a corporate/clinical inspection proxy's CA installed + system-wide is honored; fully offline machines, see the air-gapped note + above. * **Memory** — the 3 mm model peaks around 2–3 GB for a thorax CT (streaming accumulator + activations); the 1.5 mm variant needs several - GB more. Materialized masks add ≈ volume-size bytes each. + GB more, and each materialized mask ≈ volume-size bytes. * As with everything in this viewer: research and QA use — not a medical device, not for clinical decision-making. diff --git a/docs/body-contour.md b/docs/body-contour.md index 4de4215..4ba6082 100644 --- a/docs/body-contour.md +++ b/docs/body-contour.md @@ -1,172 +1,144 @@ # Automatic body / EXTERNAL contouring -The outer patient surface, with the couch, the chair and the immobilisation -left outside it. Two methods in one tool — a deterministic geometric one -that needs nothing, and a model-assisted one that borrows -TotalSegmentator's openly licensed body network for the part geometry -cannot decide. Both work on CT and on MR, supine or upright. +The outer patient surface, with couch, chair and immobilisation left outside +it. Two methods in one tool: a deterministic geometric one that needs nothing, +and a model-assisted one that borrows TotalSegmentator's openly licensed body +network for what geometry cannot decide. Both work on CT and MR, supine or +upright. ## Why this contour and not another -Every downstream calculation starts here. A dose engine needs to know where -the patient begins, because that is where the range budget starts — for -protons a 3 mm error in the entrance surface is a 3 mm error in every -distal edge behind it. A DRR needs to know what is *not* patient, or it -projects the couch straight through the anatomy. A registration wants to -sample inside the body and nowhere else, or it spends its iterations -aligning the table. - -And it is the contour nobody wants to draw. So it has to be right on the -first scan of the day, with no parameters touched. +Every downstream calculation starts here. A dose engine's range budget starts +at the entrance surface — for protons a 3 mm error there is a 3 mm error in +every distal edge behind it; a DRR that does not know what is *not* patient +projects the couch through the anatomy; a registration that samples outside +the body spends its iterations aligning the table. And nobody wants to draw +it, so it has to be right on the first scan of the day, untouched. ## What makes it hard -Not the skin — that is the largest step in the image and any threshold -finds it. The difficulty is everything else in the field of view: +Not the skin — the largest step in the image, which any threshold finds — but +everything else in the field of view: -* a **couch top** is two carbon skins around a foam core, and the skins are - as dense as bone; -* a **thermoplastic mask**, a **headrest shell**, a **vacuum-bag** fabric - and a **chair backrest** are all thin, dense and *touching the patient*, - so in a threshold mask they are one connected object with them; +* a **couch top** is two carbon skins, as dense as bone, round a foam core; +* a **thermoplastic mask**, a **headrest shell**, a **vacuum-bag** fabric and + a **chair backrest** are thin, dense and *touching the patient*, so a + threshold joins them to it; * **blankets, cables, positioning pads** drift in and out of the field; * **the reconstruction circle** leaves a bright rim on some scanners; -* and the patient is not always one object — a leg scan is two, an arm cut - off by the field of view is another. - -Meanwhile the parts of the patient that look most like equipment — an ear, -a nose, a fingertip — are exactly the thin structures that any method -aggressive enough to remove a mask will also remove. +* and the patient is not always one object — a leg scan is two, an arm cut off + by the field of view is another. ## Using it *Tools ▶ 👤 Body-contour dataset A/B…*, or the **👤 Body…** button in the -sidebar *Segmentations* section. The window follows the shared layout of -the segmentation tools (see -[architecture.md](architecture.md#the-segmentation-tool-windows)). +sidebar *Segmentations* section. The window shares the segmentation tools' +layout (see [architecture.md](architecture.md#the-segmentation-tool-windows)). * **Method** — *Classical* or *Model-assisted*; everything below adapts. -* **Tissue above** — on CT a Hounsfield threshold (default −300 HU); on MR - a fraction of the bias-corrected 99th percentile, or Otsu. The window - re-seeds this whenever the displayed series changes modality, because a - CT threshold is meaningless on MR and the reverse is just as wrong. +* **Tissue above** — on CT a Hounsfield threshold (default −300 HU); on MR a + fraction of the bias-corrected 99th percentile, or Otsu. Re-seeded whenever + the displayed series changes modality. * **Name** and **as EXTERNAL structure** — the mask lands as an ordinary - editable segmentation, and optionally also as an RTSTRUCT ROI of - interpreted type `EXTERNAL`, which is the tag a planning system looks for. - It then renders like any ROI and rides the DICOM export. + editable segmentation, optionally also as an RTSTRUCT ROI of interpreted + type `EXTERNAL`, the tag a planning system looks for; it then renders like + any ROI and rides the DICOM export. * **Options** — the smallest body detail, the equipment test with its shell - thickness and repeat window, the smallest body part, thin-anatomy - recovery, whether the body is reported solid, surface smoothing, and - (model-assisted only) the network margin, compute device and model - folder. + thickness and repeat window, the smallest body part, thin-anatomy recovery, + whether the body is reported solid, surface smoothing, and (model-assisted + only) the network margin, compute device and model folder. **▶ Contour** runs on a background thread with the usual progress row and -Cancel. The status line reports the body volume, how many separate bodies -were kept, how much equipment was removed and how much thin anatomy was -given back — which is the number to watch: a run that removes nothing is a -run whose threshold or opening radius is wrong. +Cancel. The status line reports body volume, bodies kept, equipment removed +and thin anatomy given back — the number to watch: a run that removes nothing +has the wrong threshold or opening radius. ## Method A — classical -Deterministic, nothing to download, a few seconds on a whole-body CT, and -every step explainable to a physicist doing QA. +Deterministic, nothing to download, and every step explainable to a physicist +doing QA. ### 1. Foreground -CT thresholds directly: −300 HU sits in the gap between air and fat. (The -skin edge moves about half a millimetre per 100 HU through the -partial-volume ramp — this is the one number worth agreeing on with your -planning system.) - -MR has no absolute scale: the same tissue is a different number on the next -sequence, and a different number again on the other side of the same slice, -because the receive coils shade the image. So the MR path divides the image -by a heavy blur of itself (σ = 40 mm by default) — a poor man's N4, no -iteration and no histogram model — which flattens the shading while leaving -every edge intact, then thresholds at a low fraction of the 99th percentile. -A low fraction rather than Otsu by default: Otsu splits *bright from dark*, -not tissue from air, so it runs high on fat-suppressed series and bites into -subcutaneous fat. +CT thresholds directly: −300 HU sits in the gap between air and fat. (The skin +edge moves about half a millimetre per 100 HU through the partial-volume ramp +— the one number worth agreeing with your planning system.) + +MR has no absolute scale, and the receive coils shade each image. So the MR +path divides the image by a heavy blur of itself (σ = 40 mm by default) — a +poor man's N4 — which flattens the shading and keeps every edge, then +thresholds at a low fraction of the 99th percentile. Not Otsu by default: Otsu +splits *bright from dark*, not tissue from air, so it bites into subcutaneous +fat on fat-suppressed series. ### 2. Equipment, by two geometric facts -Equipment is separated from anatomy without knowing what either looks like, -using two properties no patient has together: - -**It is thin.** An opening removes every shell whose largest inscribed ball -is smaller than its radius, while leaving everything thicker with its -surface *exactly* intact — an opening is the union of every ball that fits -inside the mask, so the ball rolls along the inside of the skin and touches -every point of it. The distance transform behind it is the exact -anisotropic Euclidean one, so the radius means the same along every axis -whatever the slice thickness, and the cost does not depend on it. - -The radius here is **2 mm**, not the 8 mm used later to decide what is big -enough to be a body, and the difference matters more than anything else on -this page. A couch skin is one or two millimetres of carbon and a -thermoplastic mask two or three; the thinnest tissue anyone would miss — -the chest wall over a lung — is five or six. At 2 mm the two are cleanly -separated. At 3 mm a six-millimetre chest wall becomes a candidate too, and -since it repeats slice after slice it is then indistinguishable from a -couch skin: the ribcage goes with the table. (This is not hypothetical. It -is what the first version of this code did to the bundled 4D-Lung study, -and it is why the test suite contains a hollow cylinder.) - -**It is extruded.** A couch top, a backrest, a seat pan, an arm rest: each -is a surface swept along one axis, so its footprint in the orthogonal plane -repeats slice after slice after slice. A pinna is 25 mm long, a nose 30 mm, -a fingertip 15 mm. Requiring a footprint to repeat over 150 mm in 80 % of -that window's slices separates them with room to spare — and the test runs -along **all three** axes, because a supine couch is extruded along z while -an upright chair's seat pan and arm rests are extruded along x. - -Being thin alone is not enough to be discarded, and neither is repeating; -only both together mark a voxel as equipment. +Equipment is separated from anatomy without knowing what either looks like, by +two properties no patient has together — only both, never one alone, mark a +voxel as equipment: + +**It is thin.** An opening removes every shell whose largest inscribed ball is +smaller than its radius and leaves everything thicker with its surface +*exactly* intact. Its distance transform is the exact anisotropic Euclidean +one, so the radius means the same along every axis whatever the slice +thickness, at a cost independent of it. + +The radius is **2 mm**, not the 8 mm used later to decide what is big enough +to be a body. A couch skin is one or two millimetres of carbon, a +thermoplastic mask two or three, and the thinnest tissue anyone would miss — +the chest wall over a lung — five or six. 2 mm separates them cleanly; at 3 mm +a six-millimetre chest wall is a candidate too, repeats slice after slice like +a couch skin, and the ribcage goes with the table — as the first version of +this code proved on the bundled 4D-Lung study; hence the hollow cylinder in +the test suite. + +**It is extruded.** A couch top, backrest, seat pan or arm rest is a surface +swept along one axis; its footprint in the orthogonal plane repeats slice +after slice, while a pinna is 25 mm long, a nose 30 mm, a fingertip 15 mm. +Requiring a footprint to repeat over 150 mm in 80 % of that window's slices +separates them with room to spare — along **all three** axes, since a supine +couch is extruded along z and an upright chair's seat pan and arm rests along +x. ### 3. A body is a solid object -A threshold does not see a body. It sees a shell of tissue wrapped round two -lungs, a stomach and a bowel — and left that way, the chest wall over a lung -is a thin sheet that repeats slice after slice, which is to say -indistinguishable from a couch skin by the rules just described. So the -interior is closed *before* any of the size reasoning below: fill what the -slice border cannot reach, and the wall becomes part of a solid object +A threshold sees not a body but a shell of tissue round lungs, stomach and +bowel — and left that way, the chest wall over a lung is a thin repeating +sheet: a couch skin by the rules above. So the interior is closed *before* any +size reasoning — fill what the slice border cannot reach and the wall is solid again. -Slice by slice, not in three dimensions — the lungs drain to the outside air -through the trachea, so on any scan that includes the neck a 3-D fill leaves -both lungs open, while slice by slice they close. (An open mouth stays a -cavity, which is the usual convention, and so does a lung on the two or -three slices where the airway is actually open to the air.) +Slice by slice, not in three dimensions: the lungs drain to the outside air +through the trachea, so on any scan including the neck a 3-D fill leaves them +open while slice by slice they close. (An open mouth stays a cavity, the usual +convention, as does a lung on the two or three slices where the airway is +actually open.) -The order is the point: **equipment first, then fill**. Fill first and a -couch top with a closed profile becomes a solid slab before anything has a -chance to recognise it. +Order matters — **equipment first, then fill**: fill first and a couch top +with a closed profile becomes a solid slab before anything can recognise it. ### 4. Which components are a patient -The opened mask is split into 6-connected components, and every component -of at least 50 cm³ is kept — *not* merely the largest, so a leg scan comes -out as two bodies and a truncated arm as a third. (A shared corner is not -contact: 6-connectivity is what stops a couch rail grazing the skin -diagonally from merging with it.) +The opened mask is split into 6-connected components and every component of at +least 50 cm³ is kept — *not* merely the largest, so a leg scan comes out as +two bodies and a truncated arm as a third. (A shared corner is not contact: +6-connectivity stops a couch rail grazing the skin diagonally from merging +with it.) ### 5. Giving the thin anatomy back -The 8 mm opening shaved a rim off the body, and took the ears with it. Two -questions put it back, because there are two different things in there. +The 8 mm opening shaved a rim off the body and took the ears with it. Two +questions put it back. -What the opening removed from the body's **own surface** lies, by -construction, within one opening radius of what is left of it — a skin rim, -the edge of a shoulder, the sharp flank of a cross-section. It can run the -whole length of the scan and still be nothing but patient, so its size is -not asked about; it is simply given back. +What it removed from the body's **own surface** — a skin rim, the edge of a +shoulder, the sharp flank of a cross-section — lies by construction within one +opening radius of what is left, can run the whole length of the scan and still +be nothing but patient, and is given back without asking its size. -What stands **clear** of that is a separate object that happens to touch: -an ear, a nose or a fingertip, which are small, or a pad, a blanket or a -bolus, which are not. There, size is exactly the right question, and -anything more than 100 mm across stays out. Two rounds, because a fingertip -hangs off a finger. +What stands **clear** of that is a separate object that happens to touch: an +ear, a nose or a fingertip, which are small, or a pad, a blanket or a bolus, +which are not — so there size is the question, and anything more than 100 mm +across stays out. Two rounds, because a fingertip hangs off a finger. ### 6. Surface @@ -175,12 +147,11 @@ An optional closing at the end takes the staircase off the contour. ### Where it fails, stated plainly Where a shell touches the skin with **no air gap at all** — the mask on the -forehead and chin, a bare couch skin under the back, a bolus — the shell's -thickness over the contact patch stays inside the body. It is 2–5 mm, over -the contact patches only, and it is not detectable by geometry, because -locally it *is* a slightly thicker patient. In practice cushions mean bare -couch contact is rare, and bolus in the external is the convention anyway. -The model-assisted method is the answer to the rest. +forehead and chin, a bare couch skin under the back, a bolus — its thickness +over the contact patch, 2–5 mm, stays inside the body: locally it *is* a +slightly thicker patient, and no geometry can tell. Cushions make bare couch +contact rare, bolus in the external is the convention anyway, and the +model-assisted method answers the rest. ## Method B — model-assisted @@ -194,112 +165,102 @@ Apache-2.0 licence as its "total" task, in three flavours: | MR | 597 | 3.0 × 1.19 × 0.99 mm | 230 MB | They run through the *same* engine as the 117-class auto-segmentation — -`autoseg::run_specs`, the same `PlainConvUNet` rebuilt from `plans.json`, -the same sliding window, the same CPU/GPU choice — and their weights live -beside it in `models/totalsegmentator/`. The model manager lists them like -any other. +`autoseg::run_specs`, the same `PlainConvUNet` rebuilt from `plans.json`, the +same sliding window and CPU/GPU choice — with weights beside it in +`models/totalsegmentator/`, and the model manager lists them like any other. -The network is **not** used as the answer. It is planned at 6 mm or 1.5 mm; -its boundary is far too coarse to be a skin surface. It is used as a -*classifier*: +Planned at 6 mm or 1.5 mm, the network is far too coarse for a skin surface, +so it is **not** the answer but a *classifier*: ``` body = threshold(image) ∧ dilate(network_body, 6 mm) ``` -The network decides **what** is patient — it removes a mask contact patch or -a couch sliver semantically, which no geometry can — and the threshold still -decides **where** the skin is, at full image resolution. The result then goes -through exactly the same components / thin-recovery / fill / closing steps as -the classical method. +The network decides **what** is patient — removing a mask contact patch or a +couch sliver semantically, which no geometry can — and the threshold decides +**where** the skin is, at full image resolution. The result then goes through +the same components / thin-recovery / fill / closing steps as the classical +method. -The network's own two classes (`body_trunc`, `body_extremities`) are cleaned -first the way the reference implementation cleans them: the trunk is one -object, so only its largest blob survives; extremities are several, so they -are filtered at 50 000 mm³ — the same constant used upstream. The body is -the union. +Its two classes (`body_trunc`, `body_extremities`) are first cleaned as the +reference implementation does — the trunk keeps only its largest blob, the +extremities are filtered at 50 000 mm³, the same constant used upstream — and +the body is their union. -The equipment test still runs. It has little left to do once the network has -answered, but the guide is used *dilated* — 6 mm by default — and a margin -that generous can pull a touching rail back in. Two cheap passes over the -volume are a fair price for not having to think about that. +The equipment test still runs: little is left for it once the network has +answered, but the guide is used *dilated* — 6 mm by default — and that margin +can pull a touching rail back in. ### What is new in the engine for this -Supporting the MR body model meant three additions, all of them additive — -the existing models take exactly the code path they took before, so their -numerics are untouched: +The MR body model needed three additions, all additive — existing models take +exactly the code path they took before, so their numerics are untouched: -* `ZScoreNormalization` alongside `CTNormalization`. Where CT normalizes - against dataset constants from `plans.json`, MR normalizes against *this - image*, so its constants are only knowable after resampling. +* `ZScoreNormalization` alongside `CTNormalization`: CT normalizes against + dataset constants from `plans.json`, MR against *this image*, so its + constants are only knowable after resampling. * **Anisotropic target spacing.** The MR model plans 3.0 × 1.19 × 0.99 mm; `SarMap` now takes a spacing per axis rather than one number. -* **A general transposed convolution.** The MR decoder upsamples - `[1, 2, 2]` at two of its five stages. The hand-tuned 2× routine is still - what every isotropic model uses; a general `kernel = stride` version - handles the rest, on CPU and through burn on the GPU. +* **A general transposed convolution.** The MR decoder upsamples `[1, 2, 2]` + at two of its five stages. Isotropic models keep the hand-tuned 2× routine; + a general `kernel = stride` version handles the rest, on CPU and through + burn on the GPU. ## Cost -The classical method is a handful of distance transforms and flood fills. -Measured on the bundled 4D-Lung study — 512 × 512 × 133 at 0.98 × 0.98 × -3 mm, on two throttled cores — it takes **13.8 s** end to end and reports a -23.2 L body with 85 cm³ of couch left out. It allocates about one byte per -voxel per intermediate mask, plus four bytes per *set* voxel for the -component lists. +The classical method is a handful of distance transforms and flood fills: on +the bundled 4D-Lung study — 512 × 512 × 133 at 0.98 × 0.98 × 3 mm, two +throttled cores — **13.8 s** end to end, reporting a 23.2 L body with 85 cm³ +of couch left out. It allocates about one byte per voxel per intermediate +mask, plus four bytes per *set* voxel for the component lists. -The model-assisted method adds one nnU-Net inference: 34 s for the 6 mm -model on the same two cores (50 s in total), minutes with the 1.5 mm or MR -model. Since the network only has to say which side of the skin a voxel is -on, **6 mm is the sensible default** — the resolution comes from the -threshold, not from it. +The model-assisted method adds one nnU-Net inference: 34 s for the 6 mm model +on the same cores (50 s in total), minutes with the 1.5 mm or MR model. The +network only says which side of the skin a voxel is on, so **6 mm is the +sensible default**. ## Verification -First, on real data. The bundled 4D-Lung study carries a real couch rail at -the bottom of the field; the classical method removes it on every slice and -follows the skin to the voxel, including the three separate pieces — two -arms and the neck — that the most superior slices contain. - -The two methods are also each other's check, and on that study they pass -it: run separately, the classical geometry and the 6 mm network agree on -**8 098 425 of 8 098 443 voxels** — eighteen voxels apart, Dice 0.999999. -Neither was tuned against the other; they simply have to be looking at the -same surface. - -Then `tests/body.rs`, which builds a phantom containing every failure mode -deliberately: an elliptical body, a couch skin and a rail under the back, a -*moulded* mask shell that stands 2 mm clear of the skin over most of its -span and presses against it over a patch, ears thin enough for the opening -to shave off, lungs draining through an airway, a cable — with anisotropic -2 × 2 × 5 mm voxels, since that is where a voxel-counting implementation -goes wrong and a millimetre-aware one does not. It asserts: +On real data first: the bundled 4D-Lung study carries a real couch rail at the +bottom of the field; the classical method removes it on every slice and +follows the skin to the voxel, including the three separate pieces — two arms +and the neck — in the most superior slices. + +The two methods are also each other's check: run separately on that study, the +classical geometry and the 6 mm network agree on **8 098 425 of 8 098 443 +voxels** — eighteen apart, Dice 0.999999 — and neither was tuned against the +other. + +Then `tests/body.rs`, whose phantom deliberately contains every failure mode — +an elliptical body, a couch skin and a rail under the back, a *moulded* mask +shell 2 mm clear of the skin over most of its span and pressed against it over +a patch, ears thin enough for the opening to shave off, lungs draining through +an airway, a cable — on anisotropic 2 × 2 × 5 mm voxels, where a +voxel-counting implementation goes wrong and a millimetre-aware one does not. +It asserts: * Dice > 0.99 against the body it was built from; * no couch skin, rail, cable or free-standing mask shell anywhere; -* the shell **is** kept where it presses against the skin, and the total - error beyond the patient stays under 10 cm³ — the documented limitation, - pinned so that a change which quietly makes it worse is caught; +* the shell **is** kept where it presses against the skin, with the total + error beyond the patient under 10 cm³ — the documented limitation, pinned; * both ears kept, and a non-zero recovered-anatomy count; * both lungs inside the body on every slice past the airway; -* a hollow cylinder — a 6 mm wall around a cavity, beside a 2 mm couch skin - — comes out with its wall intact and its couch gone, which is the - regression test for the failure real data taught; +* a hollow cylinder — a 6 mm wall round a cavity, beside a 2 mm couch skin — + keeps its wall and loses its couch; * two separated legs come out as two bodies, not as the larger one; -* an MR version with an exponential receive gradient still comes out whole - at both ends of the field (Dice > 0.97); +* an MR version with an exponential receive gradient still comes out whole at + both ends of the field (Dice > 0.97); * a model folder that cannot exist is an error, not a panic. -`the_model_assisted_method_runs_the_published_network` is `#[ignore]`d -because it downloads 124 MB; it runs the real Dataset300 weights end to end -through the hybrid. +`the_model_assisted_method_runs_the_published_network` runs the real +Dataset300 weights end to end through the hybrid; `#[ignore]`d because it +downloads 124 MB. `src/morphology.rs`'s own tests check the pieces underneath: the distance transform against brute force on an anisotropic grid, the opening against a sheet and a block, 6-connectivity against a shared corner, slice-wise versus -3-D filling, the persistence test against an extruded rail and a bump, and -the blur against a constant and a step. +3-D filling, the persistence test against an extruded rail and a bump, and the +blur against a constant and a step. ## Command-line tool @@ -313,19 +274,17 @@ cargo run --release --example body_cli -- \ [--models DIR] [--device auto|gpu|cpu] [--out mask.bin] ``` -For batch checks over a folder of scans: `--out` writes a raw `u8` mask on -the original grid, one byte per voxel in `Volume::data` order — the same -convention as the other example tools, so masks can be compared byte for -byte between methods. +For batch checks over a folder of scans, `--out` writes a raw `u8` mask on the +original grid, one byte per voxel in `Volume::data` order — the convention of +the other example tools, so masks compare byte for byte between methods. ## Licensing and citation The classical method has no weights and no third-party code. The model-assisted method uses TotalSegmentator's `body` and `body_mr` tasks, -which the authors publish under **Apache-2.0** as openly available for any -usage, commercial included. If you use it in academic work, cite -TotalSegmentator and nnU-Net as in +which the authors publish under **Apache-2.0** for any usage, commercial +included. In academic work, cite TotalSegmentator and nnU-Net as in [auto-segmentation.md](auto-segmentation.md#licensing-and-citation). -As with everything in this viewer: research and QA use — not a medical -device, not for clinical decision-making. +As with everything in this viewer: research and QA use — not a medical device, +not for clinical decision-making. diff --git a/docs/drr.md b/docs/drr.md index 4df6830..fb9c707 100644 --- a/docs/drr.md +++ b/docs/drr.md @@ -1,113 +1,93 @@ # Digitally reconstructed radiographs A DRR is a line integral of attenuation from a point source through the CT -to a flat detector: the simulated radiograph a treatment beam would produce, -and the image every image-guidance workflow compares a portal or kV image -against. *Tools ▶ ☢ Digitally reconstructed radiograph…* renders one — with -**two independent forward projectors**, because there is more than one -honest way to compute it and the difference between them is worth seeing. +to a flat detector — the simulated radiograph image guidance compares a +portal or kV image against. *Tools ▶ ☢ Digitally reconstructed +radiograph…* renders one with **two independent forward projectors**. ## The two projectors ### Siddon — plastimatch's exact ray tracer `drr -i exact`, after Siddon (Med. Phys. 1985) with Jacobs' incremental -formulation. The ray is intersected with the three families of voxel planes -and each voxel contributes exactly the length of ray inside it: the -algorithm walks voxel to voxel, always crossing whichever plane comes next, -and never allocates or sorts. There is no interpolation and no sampling -step — for a piecewise-constant volume the result *is* the integral, which -makes it the reference the other projector is checked against. Edges come -out hard, because in the voxel model they are. +formulation: the ray walks voxel to voxel and each voxel contributes +exactly the length of ray inside it. For a piecewise-constant volume the +result *is* the integral, the reference for the other projector; edges +come out hard, because in the voxel model they are. ### Ray-cast — the ITK / elastix-stack interpolating projector `itk::RayCastInterpolateImageFunction`, the projector behind ITK's 2-D/3-D -registration metrics. The ray is marched at a fixed step and trilinearly -interpolated values are accumulated with a midpoint rule. The volume is -treated as a smooth field rather than a set of boxes, so edges are softer -and the step size is a real accuracy/speed knob rather than a formality. - -Running both on the same geometry and subtracting is the point: the -difference image and its statistics (max, mean absolute, RMS, relative, and -the Pearson correlation of the two images) are a direct measure of the -interpolation error you accept by choosing either. On a uniform phantom at -0.5 mm step the two agree to r > 0.999 and a few percent mean difference, -concentrated — as it must be — on the edges. +registration metrics: the ray is marched at a fixed step, trilinearly +interpolated values accumulated with a midpoint rule — the volume as a +smooth field, so edges are softer and step size a real accuracy/speed knob. + +The difference image of the two on the same geometry and its statistics +(max, mean absolute, RMS, relative, Pearson correlation) measure the +interpolation error; on a uniform phantom at 0.5 mm step the two agree to +r > 0.999 and a few percent mean difference, concentrated on the edges. ## Geometry -[`Geometry`] is a cone-beam geometry in IEC 61217 terms, because that is how -a linac states it and how an RTPLAN beam stores it: +[`Geometry`] is a cone-beam geometry in IEC 61217 terms — how a linac +states it and an RTPLAN beam stores it: * **SAD / SID** — source-to-axis and source-to-imager distances, mm. -* **Gantry angle** — 0° puts the source directly above the patient, 90° at - the patient's left. +* **Gantry angle** — 0° source above the patient, 90° at the patient's left. * **Couch angle** — patient-support rotation about the vertical axis. -* **Isocentre** — in patient coordinates; the ⌖ button takes it from the - dataset's crosshair. -* **Panel size and pixel count** — the window reports the resulting - resolution projected back to the isocentre plane, which is the number that - matters when comparing against a real image. - -The IEC fixed frame is mapped to the DICOM patient frame for a head-first -supine patient: `Xf` (patient left) = `+x`, `Yf` (the gantry rotation axis, +* **Isocentre** — in patient coordinates; ⌖ takes the dataset's crosshair. +* **Panel size and pixel count** — the window reports the resolution + projected back to the isocentre plane. + +The IEC fixed frame maps to the DICOM patient frame for a head-first supine +patient: `Xf` (patient left) = `+x`, `Yf` (the gantry rotation axis, towards the head) = `+z`, `Zf` (vertical, up) = `−y`. Unit tests assert the -source position at 0° and 90°, and that the detector axes stay orthonormal -and perpendicular to the beam at every gantry/couch combination. +source position at 0° and 90° and the detector axes orthonormal and +perpendicular to the beam. -**From beam** takes the gantry angle, the couch angle and the isocentre from -a beam of the loaded plan — the beam's-eye view it would actually deliver. +**From beam** takes gantry angle, couch angle and isocentre from a beam of +the loaded plan. ## Values * **Attenuation (μ from HU)** — `μ = μ_water · (1 + HU/1000)`, clamped at zero, with `μ_water = 0.0206 mm⁻¹` (≈ 60 keV, the effective energy - plastimatch's DRR preprocessing assumes). The integral is then a real - optical depth: 40 mm of water on the central axis integrates to - `0.0206 × 40`, which is what the unit test checks, for both projectors. -* **Raw line integral** — integrate the values as they are (plastimatch - `-h none`). No physics, but it is what you want when comparing against - another tool's raw output. -* **Threshold** — voxels below it contribute nothing, which is the standard - way to keep air and the couch out of a DRR. + plastimatch's DRR preprocessing assumes); the integral is a real optical + depth — 40 mm of water on the central axis integrates to `0.0206 × 40`, + unit-tested for both projectors. +* **Raw line integral** — the values as they are (plastimatch `-h none`); + for comparing against another tool's raw output. +* **Threshold** — voxels below it contribute nothing, keeping air and the + couch out. ## Display -The two renderings are shown side by side with a shared display window -(black/white points as fractions of the value range), an invert toggle — -radiographs are usually read dark-on-light — and a **Difference** view that -maps the signed difference blue↔red about a grey zero. +The two renderings sit side by side with a shared display window +(black/white points as fractions of the value range), an invert toggle, and +a **Difference** view mapping signed difference blue↔red about a grey zero. ## Into the data tree -*➕ Add to dataset A/B* files the current rendering (or both, when the two -projectors were run together) under **Planar images** in that dataset's -tree. A DRR *is* an RT Image, so once it is one it inherits everything the -tree already does: its own viewer window with window/level and the correct -physical aspect ratio, renaming, and travelling with the dataset when it is -copied or moved. - -The geometry that produced it rides along as the info rows the planar viewer -lists — engine, SAD/SID, gantry and couch angles, isocentre, panel size, HU -model, threshold, sampling step (ray-cast only) and render time — so a -radiograph that has been sitting in the tree for an hour can still say -exactly what it is. Labels are `DRR Siddon · G 90° C 0°` and are made unique -on the way in, because rendering the same geometry twice is what one does -while tuning it. - -Whichever greyscale the window is showing is what gets stored: with -**Invert** on (the default) the values are mirrored about the middle of the -range so dark is high attenuation, as on a radiograph. The range itself is -unchanged either way, and the info rows say which convention was used. +*➕ Add to dataset A/B* files the rendering (or both, when run together) +under **Planar images** in the dataset's tree as an RT Image, with its own +viewer (window/level, correct physical aspect ratio), renaming, and travel +with the dataset when copied or moved. + +The producing geometry rides along as the planar viewer's info rows — +engine, SAD/SID, gantry and couch angles, isocentre, panel size, HU model, +threshold, sampling step (ray-cast only) and render time. Labels are +`DRR Siddon · G 90° C 0°`, made unique on the way in. + +Whichever greyscale the window shows is what gets stored: with **Invert** +on (the default) values are mirrored about the middle of the range so dark +is high attenuation, as on a radiograph; the range itself is unchanged, and +the info rows say which convention was used. Planar images are viewer-side objects: they are not written by *File ▶ Export dataset*, which covers CT, RTSTRUCT, SEG, RTDOSE and RTPLAN. ## Where it fits -DRR generation is a *simulation* feature, not a registration one: it shares -no code with [registration.md](registration.md). It is, however, the natural -input to 2-D/3-D registration, which is why the interpolating projector is -the one ITK's 2-D/3-D metrics use — and why having the exact one beside it, -on the same geometry, is worth the second implementation. +DRR generation is a *simulation* feature sharing no code with +[registration.md](registration.md); it is, however, the natural input to +2-D/3-D registration, whose ITK metrics use the interpolating projector. diff --git a/docs/dvh.md b/docs/dvh.md new file mode 100644 index 0000000..72f6bc6 --- /dev/null +++ b/docs/dvh.md @@ -0,0 +1,142 @@ +# Dose–volume histograms + +Cumulative and differential DVHs of any structures against any loaded dose +objects, with the metrics table, protocol constraint checking and CSV export. + +## Opening it + +*Tools ▶ 📊 Dose–volume histograms…*, or tick structures in the data tree, +right-click and choose **📊 Plot … on a DVH**: the window opens with them +already picked and the viewport's dose object already selected. + +Like every tool window it goes through [the detach +mechanism](architecture.md#tool-windows): ⧉ puts it on its own top-level +window — for a DVH the normal way to work, curves on one screen, images on the +other. + +## What it computes + +For every (structure, dose object) pair: + +* the **cumulative** histogram — volume receiving at least each dose, the + curve every constraint is read off; +* the **differential** histogram — volume per dose bin, where a cold spot + inside a target shows up as a second hump; +* the statistics: minimum, mean and maximum; +* whatever metrics the table is asked for. + +Structures may come from either dataset and either kind — RT structure or +segmentation — and any number of dose objects may be overlaid. Structures keep +their own colour and the dose object picks the line style, so two plans over +the same organs read as one colour in two dashes. + +## Four things it is careful about + +**Where it samples.** The structure's own lattice, not the dose grid: a CT +mask is 1 mm and a dose grid 2–3 mm, so walking the mask and interpolating the +dose gives a curve at the structure's resolution. The walk is affine, the +dose-grid coordinates stepped rather than recomputed per voxel — three adds +instead of three dot products. + +**What falls outside the dose grid.** Counted, kept, and said out loud: those +voxels enter the histogram at zero dose — the honest reading of "not +irradiated by *this* dose object" — and, since a DVH silently computed over 60 +% of a structure looks cold rather than truncated, a warning line names every +structure that extends outside the grid and by how much. + +**Statistics from the samples, not the bins.** Minimum, mean and maximum are +accumulated during the walk; reading them off a binned histogram costs half a +bin width of accuracy for nothing. + +**Interpolation inside a bin — except the lowest.** D95 % is almost never +exactly at a bin edge, so the cumulative curve is interpolated linearly +between edges. The lowest bin holds exact zeros, so a reading inside it +returns 0 rather than a few hundredths of a Gy that would look like a real +dose. + +The histogram uses 2000 bins over the dose maximum — 3 cGy on a 60 Gy plan, +finer than any constraint is quoted to. + +## The axes + +Both are switchable, independently: + +| | | +|---|---| +| **Dose** | Gy, or per cent of a reference dose | +| **Volume** | per cent of each structure, or cm³ | + +The reference defaults to the prescription of the first plan that declares +one, and the window says which plan that was; ↺ restores it after you have +typed something else. Dose-valued table columns follow the same switch, so +`Dmean` reads in per cent when the axis does. + +## The metrics table + +One row per curve. It starts with volume, minimum, mean, maximum, D95 % and D2 +%, and takes any column you type: + +| You type | You get | +|---|---| +| `Dmean`, `Dmax`, `Dmin` | the statistics | +| `D95%`, `D2%` | dose to at least that percentage of the structure | +| `D2cc`, `D0.1cc` | dose to at least that absolute volume | +| `V20`, `V20Gy` | percentage of the structure at or above that dose | +| `V20cc` | the same as an absolute volume | + +## Constraint checking + +A protocol is a plain text file, one constraint per line — human-editable on +purpose — that is how a department keeps them: + +``` +# head and neck, 30 fractions +PTV* D95% >= 57 +PTV* Dmax <= 63 +Cord Dmax <= 45 +"Parotid L" Dmean <= 26 +Lung* V20Gy <= 30 +``` + +The structure name is matched case-insensitively; a leading or trailing `*` +matches loosely, so `PTV*` catches `PTV_5400`; names with spaces are quoted. +The header line of the collapsing section says how many constraints are met, +and each row shows ✔ or ✖ against the value. + +A constraint that matches **no** structure is reported with a dash and does +**not** pass — a line that quietly evaluates to "fine" because the structure +was never contoured is the worst failure mode a checker can have. + +## Export + +**Export curves…** writes the cumulative curves as CSV: one dose column, then +one volume column per structure, following the volume axis currently shown. +Curves against different dose objects may differ in bin width, so they are +resampled onto one dose axis at the finest of them rather than assumed to +share one. + +**Export table…** writes the metrics table as it stands. + +## Verification + +`src/dvh.rs`'s own tests check the arithmetic on grids built in the test: a +uniform dose gives a step and exact statistics; a linear ramp gives a DVH +linear to within 2 %, read in both directions; voxels outside the dose grid +are counted, reported, and drag D60 % to zero without disturbing the +statistics of what *was* irradiated; metric names round-trip through `label()` +and `parse()`; a protocol survives a write and re-read, quoted name included; +and the CSV puts curves with different bin widths on one axis. + +`tests/dvh.rs` goes through the whole path: the synthetic RT study is written +as DICOM, read back through the loader, its contours rasterized, and the +histogram taken against the RTDOSE as parsed. The phantom's dose is an +analytic Gaussian centred on a spherical target, so the target's DVH is known +in closed form — the volume above dose `D` is the ball of radius +`σ·√(2·ln(peak/D))` — and the test compares against that formula, not a +previous run, at six doses and three volume levels, to within 5 % of volume +and 2 Gy of dose. It also checks that a cumulative curve never rises, that a +structure contained in another is nowhere hotter in absolute volume, and that +a protocol reads the phantom the way a physicist would. + +As with everything in this viewer: research and QA use — not a medical device, +not for clinical decision-making. diff --git a/docs/example-data.md b/docs/example-data.md index 49e7bf9..833d8fd 100644 --- a/docs/example-data.md +++ b/docs/example-data.md @@ -1,8 +1,8 @@ # Bundled example data `example_data/` holds a small real patient study (137 MB) so the viewer -can be exercised on clinical data and not only on the synthetic phantom — -two breathing phases of a 4DCT, each with its own RT Structure Set: +can be exercised on clinical data, not only the synthetic phantom — two +breathing phases of a 4DCT, each with its own RT Structure Set: ``` example_data/ @@ -22,12 +22,13 @@ cargo run --release -- example_data/lung_p1_4DCT_phase_000 example_data/lung_p1_ ``` That is a ready-made comparison-mode and registration test case with real -respiratory motion: the tumor and the markers move visibly between the -phases, and the deformable methods of the *Image registration* module -have something anatomically real to recover. Equivalently, load the whole `example_data/` folder as dataset A -(both phases appear as two series of one study) and right-click one phase -▶ *Copy series to dataset B*. It is also the dataset the auto-segmentation -was validated on ([auto-segmentation.md](auto-segmentation.md#validation)). +respiratory motion: the tumor and markers move visibly between the phases, +and the deformable methods of the *Image registration* module have +something anatomically real to recover. Equivalently, load the whole +`example_data/` folder as dataset A (both phases appear as two series of +one study) and right-click one phase ▶ *Copy series to dataset B*. It is +also the dataset the auto-segmentation was validated on +([auto-segmentation.md](auto-segmentation.md#validation)). ## Source and citation diff --git a/docs/export-and-tools.md b/docs/export-and-tools.md index b724a66..353b78e 100644 --- a/docs/export-and-tools.md +++ b/docs/export-and-tools.md @@ -1,30 +1,27 @@ # DICOM export, model manager, anonymizer and test-data generator -Four tools round out the workflow: writing datasets back out as DICOM, -managing the downloaded network weights, anonymizing folders on disk, and -generating a fully synthetic RT study for testing. (The *Tools* menu also -holds the three segmentation engines — see +The *Tools* menu also holds the three segmentation engines — see [auto-segmentation.md](auto-segmentation.md), [segvol.md](segvol.md) and -[medsam2.md](medsam2.md) — plus structure -[propagation](propagation.md) and [DRR generation](drr.md), which have their -own documents.) +[medsam2.md](medsam2.md) — plus structure [propagation](propagation.md), +[DRR generation](drr.md) and the local [patient archive](pacs.md), which have +their own documents. Export writes a folder; the archive writes into the +application's own store, with the same DICOM writer underneath — see +[pacs.md](pacs.md) for when to reach for which. ## DICOM export Any loaded dataset — original, simulated or with converted segmentations — -can be exported as a set of DICOM files via *File ▶ 💾 Export dataset A/B as -DICOM…*: one CT Image Storage file per slice plus RTSTRUCT, one binary -Segmentation (SEG) object per segmentation series, RTDOSE (16-bit with -`DoseGridScaling`) and an RTPLAN skeleton (photon or ion), written with -`dicom-rs` in Explicit VR Little Endian and preserving the RTSTRUCT ▶ series, -SEG ▶ series and RTDOSE ▶ RTPLAN ▶ RTSTRUCT reference chains. A SEG only -claims the exported image slices as its source when it sits on their -lattice. Fresh `2.25.…` UIDs are generated -for the new objects. Export runs on a background thread with progress. - -The dialog first shows what will be written, in the same shape as the -anonymizer: an output folder, then every patient / study / equipment -attribute that goes into all exported files — +can be exported as DICOM files via *File ▶ 💾 Export dataset A/B as DICOM…*: +one CT Image Storage file per slice plus RTSTRUCT, one binary Segmentation +(SEG) object per segmentation series, RTDOSE (16-bit with `DoseGridScaling`) +and an RTPLAN skeleton (photon or ion), written with `dicom-rs` in Explicit +VR Little Endian and preserving the RTSTRUCT ▶ series, SEG ▶ series and +RTDOSE ▶ RTPLAN ▶ RTSTRUCT reference chains. A SEG claims the exported image +slices as its source only when it sits on their lattice; the new objects get +fresh `2.25.…` UIDs. Export runs on a background thread with progress. + +The dialog shows, in the anonymizer's shape, an output folder and every +patient / study / equipment attribute written into all exported files — | Tag | Default | |---|---| @@ -36,31 +33,27 @@ attribute that goes into all exported files — | InstitutionName, StationName | empty | | Manufacturer, ManufacturerModelName | `rust-dicom-station`, `DICOM export` | -Every value is editable, `↺` restores the study's own value (`↺ all` restores -the whole table), and unchecking a row leaves that tag out of the files -entirely. *StudyDate* / *StudyTime* also stamp the RTSTRUCT and RTPLAN -date/time. **Keep the source Frame of Reference UID** (on by default) keeps -the export spatially linked to its source, so the two load as a comparable -pair; switching it off generates a fresh frame of reference. +Every value is editable, `↺` restores the study's own value (`↺ all` the whole +table), and unchecking a row leaves that tag out of the files. *StudyDate* / +*StudyTime* also stamp the RTSTRUCT and RTPLAN date/time. **Keep the source +Frame of Reference UID** (on by default) keeps the export spatially linked to +its source; switched off, a fresh frame of reference is generated. -A single segmentation series can also be written on its own, without -exporting the dataset around it: right-click the series in the data tree and -choose *💾 Export as DICOM SEG…*. - -The exports round-trip through this viewer and pydicom; they are -QA/research objects, not guaranteed-complete clinical IODs. +A single segmentation series can be written on its own: right-click it in +the data tree and choose *💾 Export as DICOM SEG…*. To write only what was +*drawn* — the structure sets and segmentation series, with the images left +where they are — use *📤 Send dataset* in the [patient archive](pacs.md) +window instead. The exports round-trip through this viewer and pydicom; they +are QA/research objects, not guaranteed-complete clinical IODs. ## Model manager -The three segmentation engines each download their own weights on first use, -which is convenient right up to the moment somebody asks what is actually on -this machine, how much disk it costs, or wants a checkpoint re-fetched after -a bad download. *Tools ▶ 📦 Downloaded models…* answers all three from one -inventory. +Each segmentation engine downloads its weights on first use; *Tools ▶ 📦 +Downloaded models…* is the one inventory of what is on this machine, what it +costs in disk, and where to re-fetch a checkpoint after a bad download. -Every model of every engine gets a row: its state (ready / partly downloaded -/ missing), what it occupies on disk or would cost to fetch, and the buttons -that act on it. +Every model of every engine gets a row: its state (ready / partly downloaded / +missing), its size on disk or to fetch, and the buttons that act on it. | | | |---|---| @@ -70,67 +63,61 @@ that act on it. | 🗑 | delete every file of this model | and, over the whole inventory, **⬇ Download all missing**, **⟳ Update all** -and **🧹 Free …**, which reports how much the redundant source checkpoints -are costing before you drop them. The model folder itself is editable here -(it is the same setting the three tool windows show) and the header reports -how many models are ready and how much the lot occupies. +and **🧹 Free …**, which reports what the redundant source checkpoints cost +before you drop them. The model folder is editable here (the setting the +three tool windows show); the header counts ready models and total size. Two details worth knowing: * Preparing a model runs the **engine's own first-use path** — the same - download, the same native checkpoint conversion, the same cache. A model - fetched here is bit for bit the one a run would have fetched; there is no - second download route to keep in step. -* Removal only ever deletes the file names the inventory lists, never a - whole folder, so a model folder you also keep something else in survives - intact. The model's own sub-folder is removed afterwards if it came out - empty. - -The licence of each engine's weights is stated above its rows, because it -differs: TotalSegmentator's are Apache-2.0, SegVol's carry no licence -declaration at all, and MedSAM2's are CC-BY-SA-4.0 with a research-only -model card. None of them is ever redistributed with the program. + download, checkpoint conversion and cache — so a model fetched here is bit + for bit the one a run would have fetched. +* Removal deletes only the file names the inventory lists, never a whole + folder, so anything else kept in the model folder survives; the model's own + sub-folder is removed afterwards if it came out empty. + +Each engine's weight licence is stated above its rows: TotalSegmentator's +are Apache-2.0, SegVol's carry no licence declaration, and MedSAM2's are +CC-BY-SA-4.0 with a research-only model card. None is redistributed with the +program. ## DICOM anonymizer -*Tools ▶ 🔏 Anonymize DICOM folder…* is an interactive anonymizer for -folders on disk (independent of what is loaded): +*Tools ▶ 🔏 Anonymize DICOM folder…* is an interactive anonymizer for folders +on disk (independent of what is loaded): 1. **Scan** (recursive, background thread): the dialog lists every - identifying tag actually present — patient identity, birth date/sex, - dates and times, accession number, physicians, institution, station, - device — with its current value(s) across the files and a proposed - replacement: a deterministic `anon_xxxxxx` patient alias derived from - the original PatientID, the fixed date `20000101` / time `000000`, or a - cleared value. Every proposal is editable, each row can be unchecked, - and Study/Series descriptions are offered opt-in. + identifying tag present — patient identity, birth date/sex, dates and + times, accession number, physicians, institution, station, device — with + its current value(s) and a proposed replacement: a deterministic + `anon_xxxxxx` patient alias derived from the original PatientID, the fixed + date `20000101` / time `000000`, or a cleared value. Every proposal is + editable, each row can be unchecked, and Study/Series descriptions are + offered opt-in. 2. **Apply** (parallel, background thread) with three switches: * **regenerate UIDs** — every non-standard UID (study, series, SOP instances, frame of reference, and every reference to them inside - sequences) is replaced with a fresh `2.25.` UID, consistently across - all files, so the reference chains stay intact; - * **remove private elements** — drops all odd-group vendor tags, - including inside sequences; + sequences) is replaced with a fresh `2.25.` UID, consistently across all + files, so the reference chains stay intact; + * **remove private elements** — drops all odd-group vendor tags, including + inside sequences; * **mark as de-identified** — writes `PatientIdentityRemoved=YES` and `DeidentificationMethod`. -Output goes to a separate folder (files keep their relative paths; default -`_anon`) or in place; files are written via a temp file so an -interrupted run never corrupts an original, and pixel data is copied -through byte-identical. `tests/anonymize.rs` verifies the pipeline -end-to-end: identity gone, reference chains resolve, volume unchanged. - -Known limitation: value replacements are applied to top-level elements; -identifying strings nested inside sequences (e.g. operator names in beam -session sequences) are not yet rewritten (UID remapping and private-tag -removal do recurse). +Output goes to a separate folder (relative paths kept; default `_anon`) +or in place; files are written via a temp file so an interrupted run never +corrupts an original, and pixel data is copied through byte-identical. +`tests/anonymize.rs` verifies the pipeline end-to-end: identity gone, +reference chains resolve, volume unchanged. Known limitation: value +replacements apply to top-level elements; identifying strings nested inside +sequences (e.g. operator names in beam session sequences) are not yet +rewritten (UID remapping and private-tag removal do recurse). ## Synthetic test-data generator -*File ▶ 🧪 Generate test data…* (also offered on the empty start screen) -writes a complete, analytically known RT study into `test_data/` next to -the executable and loads it straight away — no Python, no external -tooling: +*File ▶ 🧪 Generate test data…* (also offered on the empty start screen) writes +a complete, analytically known RT study into `test_data/` next to the +executable and loads it straight away — no Python, no external tooling: * CT — 40 slices, 96 × 96, 2 mm isotropic; water cylinder (r = 70 mm), spherical target (r = 25 mm, HU 100), cord (r = 8 mm, HU 40); @@ -139,10 +126,10 @@ tooling: * RTPLAN — ion (proton) plan, 2 beams, 60 Gy / 30 fx; * optionally DX, RTIMAGE (DRR), REG and an RT Ion Beams Treatment Record. -The dialog exposes the dose peak, a target Y shift, a whole-phantom X/Y -shift, the plan label and the REG translation — so a deliberately -misaligned second study for comparison-mode and registration testing is a -matter of generating once more into another folder: +The dialog exposes the dose peak, a target Y shift, a whole-phantom X/Y shift, +the plan label and the REG translation, so a deliberately misaligned second +study for comparison-mode and registration testing is one more generation into +another folder: ``` # rigid scenario: whole phantom translated (12, −9) mm @@ -150,6 +137,6 @@ cargo run --release -- test_data test_data_shifted ``` a rigid run in the *Image registration* module should then recover the -(12, −9, 0) mm shift to within a fraction of a millimeter. The whole phantom is analytically -known, which is what the integration tests assert against — see +(12, −9, 0) mm shift to within a fraction of a millimeter. The phantom is +analytically known, which is what the integration tests assert against — see [architecture.md](architecture.md#testing). diff --git a/docs/medsam2.md b/docs/medsam2.md index 44246ab..173fc95 100644 --- a/docs/medsam2.md +++ b/docs/medsam2.md @@ -2,20 +2,20 @@ The auto-segmentation engine ([auto-segmentation.md](auto-segmentation.md)) gives 117 fixed anatomical classes with no interaction. The prompt engine -([segvol.md](segvol.md)) segments whatever you point at, but it sees the study -through a fixed **32 x 256 x 256** window — on a 300-slice CT that is a very -coarse view, and its masks come back at a quarter of the in-plane resolution. +([segvol.md](segvol.md)) segments whatever you point at, but through a +fixed **32 x 256 x 256** window — a very coarse view of a 300-slice CT, +with masks at a quarter of the in-plane resolution. -This engine is the third answer: you mark a structure on **one** slice — a -box, a click, or a contour you already drew — and it follows that structure -through the rest of the stack at the slice's own resolution. It is a pure-Rust -re-implementation of [MedSAM2](https://github.com/bowang-lab/MedSAM2) (Ma et -al., 2025), which is SAM 2.1 fine-tuned on medical images — no Python, no -ONNX Runtime, no CUDA. +This engine is the third answer: mark a structure on **one** slice — a box, +a click, or a contour you already drew — and it follows that structure +through the rest of the stack at the slice's own resolution. It is a +pure-Rust re-implementation of +[MedSAM2](https://github.com/bowang-lab/MedSAM2) (Ma et al., 2025), SAM 2.1 +fine-tuned on medical images — no Python, no ONNX Runtime, no CUDA. -For CT, whose slices are natively 512 x 512, the network's input resolution is -the slice's own, so nothing is resampled in-plane at all and the mask is as -sharp as the image. +For CT, whose slices are natively 512 x 512, the network's input resolution +is the slice's own: nothing is resampled in-plane and the mask is as sharp +as the image. ## Using it @@ -23,54 +23,49 @@ sharp as the image. in the sidebar *Segmentations* section, opens the tool window (**⏩ Slice propagation — dataset A**; the three segmentation engines share one window layout, see [architecture.md](architecture.md#the-three-engine-windows)). -The workflow is the -one the [MedSAM2 extension for 3D -Slicer](https://github.com/bowang-lab/MedSAMSlicer/tree/MedSAM2) established -— box the structure on one slice, check that slice, then propagate — with -the round trips taken out: there is no server to configure, and the network -stays loaded between steps. - -1. **Draw the box.** Scroll to a slice where the structure is clear and drag - a rectangle around it, directly in the image. The box stays where you put - it: drag a **corner** to resize, drag the **middle** to move it, drag - anywhere else to start a new one. It belongs to the slice it was drawn on - and is shown faintly on the others, so you can see where it sits while you - scroll. It is drawn in the view whose slices the network propagates through - — the axial one for an ordinary CT — and the panel names it. +The workflow is the one the [MedSAM2 extension for 3D +Slicer](https://github.com/bowang-lab/MedSAMSlicer/tree/MedSAM2) +established — box the structure on one slice, check it, propagate — minus +the round trips: no server to configure, and the network stays loaded +between steps. + +1. **Draw the box.** Scroll to a slice where the structure is clear and + drag a rectangle around it in the image. Drag a **corner** to resize, + the **middle** to move, anywhere else to start a new one. The box + belongs to the slice it was drawn on and is shown faintly on the others + while you scroll. It is drawn in the view whose slices the network + propagates through — the axial one for an ordinary CT — and the panel + names it. 2. **Look at that one slice.** *Preview this slice* segments the prompted - slice alone. With **automatically** ticked (it is by default) that happens - every time the box changes, as soon as you let go of the mouse. The result - appears as an ordinary segmentation, so it is shaded in all three views and - in 3D. + slice alone; with **automatically** ticked (the default) that happens + whenever the box changes, as soon as you release the mouse. The result + is an ordinary segmentation, shaded in all three views and in 3D. 3. **Correct it with clicks.** Switch the tool to **➕ Include** or **➖ - Exclude** and click: green points say *this is the structure*, red ones say - *this is not*. Both go to the network together with the box, which is how - SAM was trained to be corrected. The slice is only encoded once, so each - click costs the prompt path alone — milliseconds on a GPU. -4. **Set the range and propagate.** The range starts as ±32 slices around the - box and follows it until you set it yourself; *from* / *to* take the slice - you are looking at, and *Whole study* is one click. **▶ Propagate** then - follows the structure through that range. - -The crosshair is not involved anywhere in this: while the panel is open the -left button in the drawing view belongs to the box, and the other two views -navigate as usual. + Exclude** and click: green points say *this is the structure*, red ones + *this is not*. Both go to the network with the box, which is how SAM was + trained to be corrected. The slice is encoded only once, so each click + costs the prompt path alone — milliseconds on a GPU. +4. **Set the range and propagate.** The range starts as ±32 slices around + the box and follows it until you set it yourself; *from* / *to* take the + current slice, and *Whole study* is one click. **▶ Propagate** follows + the structure through that range. + +The crosshair is not involved: while the panel is open the left button in +the drawing view belongs to the box; the other two views navigate as usual. ### Correcting a slice that drifted Propagation is a chain, and a long one eventually loses its grip — a thin neck between two lobes, a slice where the structure nearly disappears. -Scroll to the slice where it went wrong, draw a fresh box there, and -propagate again with **Add to what is already there** ticked (the default -once there is a result): the new run is unioned into the segmentation -instead of replacing it, so a correction fixes the tail without discarding -the part that was right. - -This is *not* the same thing as re-conditioning a single pass on two -prompted slices, which the architecture would also allow — it is two -independent propagations, OR-ed. It is the honest, predictable version, and -it is what the reference pipeline does too (it never uses more than one -conditioning slice per run). +Scroll to where it went wrong, draw a fresh box, and propagate again with +**Add to what is already there** ticked (the default once there is a +result): the new run is unioned into the segmentation instead of replacing +it, fixing the tail without discarding the part that was right. + +This is *not* re-conditioning a single pass on two prompted slices, which +the architecture would also allow — it is two independent propagations, +OR-ed: the honest, predictable version, and what the reference pipeline +does too (it never uses more than one conditioning slice per run). ### What the panel holds @@ -87,16 +82,16 @@ conditioning slice per run). | **Options ▸ Largest connected component** | drop everything but the biggest 26-connected blob — usually right for a single lesion | | **Options ▸ Threshold** | the logit cut, 0 by default (probability 0.5) | | **Options ▸ Compute** | *Auto* (GPU when available, else CPU), *GPU*, or *CPU* | -| **Options ▸ Model folder** | the root every engine downloads into; this engine's files go to `models/medsam2/` | +| **Options ▸ Model folder** | the root every engine downloads into; this engine's files go to its `medsam2/` sub-folder | -The result is an ordinary segmentation: editable with the brush and eraser, -visible in the 3D window, convertible to RTSTRUCT. The usual loop is -*box, preview, correct, propagate, fix by hand, export*. +The result is an ordinary segmentation: editable with brush and eraser, +visible in 3D, convertible to RTSTRUCT. The usual loop is *box, preview, +correct, propagate, fix by hand, export*. The window matters more than it looks — it **is** the model's contrast, and -changing it rebuilds the prepared stack. Everything else (the weights, and -the encoded prompted slice) survives between runs, so only the first run of -a session pays for loading. +changing it rebuilds the prepared stack. Everything else (weights, encoded +prompted slice) survives between runs, so only the first run of a session +pays for loading. ## Headless @@ -109,22 +104,22 @@ cargo run --release --example medsam2_cli -- \ [--no-cleanup] [--out mask.raw] ``` -`--models` is the engine's folder, `models/medsam2/` next to the executable +`--models` is the engine's folder, `medsam2/` in the viewer's model folder by default. Coordinates are in the *prepared* stack — axial slices in reading order, -which for an ordinary head-first-supine CT is the acquisition order. `--out` -writes one byte per voxel on the original volume's grid. +the acquisition order for an ordinary head-first-supine CT. `--out` writes +one byte per voxel on the original volume's grid. -`examples/medsam2_probe` fetches a checkpoint and checks it against the layout -the port expects, printing the tensor inventory. +`examples/medsam2_probe` fetches a checkpoint, checks it against the layout +the port expects and prints the tensor inventory. ## How it works -MedSAM2 is SAM 2.1 Hiera-Tiny with the input halved to 512; the architecture -is Meta's, unmodified, and the medical part is in the weights. A volume is -handed to it the way SAM 2 is handed a video — **slices are frames** — so the -port needs SAM 2's memory bank as well as its image encoder. +MedSAM2 is SAM 2.1 Hiera-Tiny with the input halved to 512; the +architecture is Meta's, unmodified, and the medical part is in the weights. +A volume is handed to it as SAM 2 is handed a video — **slices are frames** +— so the port needs SAM 2's memory bank as well as its image encoder. | | | |---|---| @@ -136,31 +131,30 @@ port needs SAM 2's memory bank as well as its image encoder. | Memory encoder | Mask downsampler + two ConvNeXt blocks, 64 channels out | | Parameters | 38,962,498 across 471 tensors | -Segmenting a slice that is not the prompted one means conditioning its image -features on a **memory bank**: every prompted slice, the six nearest slices -already tracked, and up to sixteen *object pointers* — 256-dimensional -summaries of what was segmented on each decided slice. The prompted slice -itself skips all of that: it gets one learned "no memory" vector instead. - -A study is then segmented in two independent passes, exactly as the reference -does it: prompt, track to the end, throw the memory away, prompt again, track -to the beginning, and OR the two results. - -Everything runs through `burn`: the whole graph is on the GPU with the `gpu` -feature (wgpu — Vulkan, DX12 or Metal, no CUDA toolkit), and on a pure-Rust -CPU backend otherwise. The panel reports which one it got. Expect roughly -48 G multiply-accumulates per slice, about half of it in the strictly -sequential memory path — which is why the propagation range is bounded by -default. - -What makes the interactive loop work is that **a prompt is cheap and a slice -is not**. Encoding a slice is the expensive half; the prompt encoder and mask -decoder that turn a box into a mask are a small fraction of it. So the engine -keeps the prompted slice's encoder output, and previewing after moving the box -or adding a click re-runs only that fraction — measured at roughly half the -cost of a cold preview on the CPU backend, and proportionally far less where -the encoder is fast. Nothing else is cached: the propagation itself is a fresh -walk each time, because its memory bank depends on the prompt. +Segmenting any slice but the prompted one conditions its image features on +a **memory bank**: every prompted slice, the six nearest slices already +tracked, and up to sixteen *object pointers* — 256-dimensional summaries of +what was segmented on each decided slice. The prompted slice skips all that +and gets one learned "no memory" vector instead. + +A study is segmented in two independent passes, exactly as the reference +does: prompt, track to the end, discard the memory, prompt again, track to +the beginning, OR the two results. + +Everything runs through `burn`: the whole graph on the GPU with the `gpu` +feature (wgpu — Vulkan, DX12 or Metal, no CUDA toolkit), on a pure-Rust CPU +backend otherwise; the panel reports which one it got. Expect roughly 48 G +multiply-accumulates per slice, about half in the strictly sequential +memory path — which is why the propagation range is bounded by default. + +The interactive loop works because **a prompt is cheap and a slice is +not**: encoding a slice is the expensive half, the prompt encoder and mask +decoder that turn a box into a mask a small fraction of it. The engine +keeps the prompted slice's encoder output, so previewing after moving the +box or adding a click re-runs only that fraction — roughly half the cost of +a cold preview on the CPU backend, proportionally far less where the +encoder is fast. Nothing else is cached: propagation is a fresh walk each +time, because its memory bank depends on the prompt. ## Preprocessing, and why it is not the other engines' @@ -171,16 +165,16 @@ clip to the window -> min-max the clipped volume to [0, 255] and quantize to u ``` There is **no resample to a target spacing and no foreground crop** — the -nnU-Net-style pipeline of the auto-segmentation engine and the -statistics-based one of SegVol would both quietly change the distribution the -weights were fitted to. The `u8` quantization is not a formality either: the -network never saw anything finer. +auto-segmentation engine's nnU-Net-style pipeline and SegVol's +statistics-based one would both quietly change the distribution the weights +were fitted to. The `u8` quantization is no formality either: the network +never saw anything finer. The resize is PIL's, not PyTorch's — a bicubic kernel with `a = -0.5`, a support that widens when shrinking, and 8-bit fixed-point arithmetic. It is reproduced bit for bit, and on 512 x 512 CT it does not run at all. -Slices are taken along the patient's superior axis and oriented the way a +Slices are taken along the patient's superior axis and oriented as a radiologist reads them: rows anterior to posterior, columns right to left. ## Divergences from the reference @@ -192,15 +186,15 @@ Three, all deliberate, all visible in the panel: hundred sequential steps, and the far end has drifted anyway. The range starts at ±32 slices around the box. 2. **The largest-component cleanup is per segmentation.** The reference - accumulates every lesion of a study into one array and then keeps the - largest connected component of the *union*, which silently deletes all but - one lesion. + accumulates every lesion of a study into one array and keeps the largest + connected component of the *union*, silently deleting all but one + lesion. 3. **The window comes from the viewport** rather than from a per-lesion CSV. -One thing that is *not* a divergence: MedSAM2 enables `fill_hole_area = 8`, -but that hole filling is a CUDA extension which falls back to a no-op on the -CPU — the reference itself only fills holes when it happens to be running on -a GPU. This port never does. +Not a divergence: MedSAM2 enables `fill_hole_area = 8`, but that hole +filling is a CUDA extension which falls back to a no-op on the CPU — the +reference itself only fills holes when it happens to run on a GPU. This +port never does. ## Weights, and their licence @@ -225,24 +219,25 @@ Cite Ma et al., *MedSAM2: Segment Anything in 3D Medical Images and Videos* ## Accuracy, and what that means here -The paper reports median Dice of 86.7 % on CT lesions (n = 409), 88.8 % on CT -organs, 88.4 % on MRI lesions and 87.2 % on PET lesions, and an 86–87 % +The paper reports median Dice of 86.7 % on CT lesions (n = 409), 88.8 % on +CT organs, 88.4 % on MRI lesions and 87.2 % on PET lesions, and an 86–87 % reduction in annotation time in its user study. -This port has been checked against the reference implementation -module by module and end to end — the trunk, the neck, the prompt encoder, the -mask decoder, the memory pair and a full ten-slice propagation all agree to -within about 5e-6 relative, which is f32 accumulation noise. That is a -statement about *fidelity to MedSAM2*, not about MedSAM2 being right on your -data: the authors' own limitations are worth knowing. +This port has been checked against the reference implementation module by +module and end to end — trunk, neck, prompt encoder, mask decoder, memory +pair and a full ten-slice propagation all agree to within about 5e-6 +relative, f32 accumulation noise. That is *fidelity to MedSAM2*, not +MedSAM2 being right on your data; the authors' own limitations are worth +knowing. * Box prompts do not suit thin, branching structures — vessels, airways. * Nothing models 3-D continuity explicitly; a strongly curved or elongated structure can drift. -* The memory bank is eight slices deep and does not adapt to slice thickness, - so thick slices and abrupt changes between them are where it loses track. +* The memory bank is eight slices deep and does not adapt to slice + thickness, so thick slices and abrupt changes between them are where it + loses track. * The far end of a long propagation is the least trustworthy part of the - result, which is what the range limit is for. + result — what the range limit is for. This software is a viewer for research and QA convenience — **not a medical device, and not for clinical decision-making.** diff --git a/docs/motion-4d.md b/docs/motion-4d.md new file mode 100644 index 0000000..d3553d0 --- /dev/null +++ b/docs/motion-4d.md @@ -0,0 +1,137 @@ +# 4D motion analysis and ITV generation + +The 4D workflow reproduces, inside the viewer, the pipeline of ITV-based +motion studies (e.g. upright-vs-supine STAR target evaluation): per-phase +registration, target propagation, centroid motion metrics, target–reference +drift and correlation, and ITV volumes. + +## 4D groups (`src/fourd.rs`) + +A 4DCT arrives as one image series per respiratory phase, usually with an +average and sometimes MIP/MinIP reconstructions beside them; DICOM stores +no node for the acquisition they form, so the viewer reconstructs one: + +- Series are bucketed by (study, modality). A series whose description + carries a number directly before a `%` (e.g. `Thorax 4D 30%`, `CT 0 % + Ex`) or `phase` + number (`4DCT_phase_000`, `Phase 3`) is a **phase**; + the description minus the number (the *template*) tells two 4D sets in + one study apart — thin- and thick-slice reconstructions become two + groups — and is the group's name stem (`4D CT — Thorax 4D (10 phases)`). +- Series with a `TemporalPositionIdentifier` but no percent group by + identical description and order by that identifier (`t1`, `t2`, …). +- `AVG`/`average`/`mean`, `MIP` and `MinIP` in the description mark the + reconstructions; they attach to the bucket's first group. +- A group needs **at least three phases** — two series with "50%" in the + name are more likely a coincidence than an acquisition. + +Detection is a heuristic, so everything can be corrected from the data +tree: right-click a series ▸ *4D group* to add it to a group or start a +new one; a group member to reorder it, change its role or remove it; the +group node to rename it, dissolve it or re-run detection. Hand-edited +groups are marked *custom* and survive re-detection (`fourd::refresh`, run +whenever the series list changes); a dissolved auto-detected group leaves +a hidden tombstone that only the explicit *Re-detect 4D groups* clears. +Members reference series by **SeriesInstanceUID**, so renames, removals +and copies never corrupt a group. + +In the tree a group renders as a `🎞` node inside its study — phases in +temporal order, then the reconstructions; grouped series leave their +modality node so each series has one place. + +## The pipeline (`src/app/motion_win.rs`) + +*Tools ▸ 📈 Motion-analyse dataset A/B…*, or right-click a 4D group ▸ +*Motion / ITV analysis…*. One run: + +1. **Reference phase** — chosen in the dialog (default: the 0 % phase). + Targets are defined on it: contours rasterized onto its lattice, + segmentations from another lattice resampled onto it. +2. **Per-phase registration** — the reference volume is registered to + every other phase with the elastix engine: a rigid stage and, for the + deformable model, a B-spline refinement *started from* the rigid + result. Settings (levels, iterations, samples, grid spacing, sampling + threshold) come from the Registration panel, adjustable in the dialog. +3. **Propagation** — every target (and the reference structure, when + chosen) is carried through each transform onto each phase's lattice, + once per model; the transform maps reference → phase, so landing on + the phase samples through the inverse, as in the propagation tool. +4. **Measurement** (`src/motion.rs`) — per phase and model: centroid (mm, + patient LPS), volume; from those: displacement from the reference + phase, 3D magnitude, peak-to-peak amplitude (largest pairwise centroid + distance), target–reference drift `|TV − ref|` and its peak-to-peak, + and Pearson correlation of target vs. reference displacement along RL / + AP / SI with two-tailed p-values (t-test, n−2 dof). +5. **ITV** — per target and model, the union of the propagated masks over + all phases, resampled onto the reference lattice, plus an optional + uniform margin. ITVs land as a segmentation series `4D ITV — ` + referencing the reference phase series (display that phase to see and + edit them; they export like any segmentation — SEG or RTSTRUCT). +6. **Registration QA** — per phase and model: the engine's metric line, + the 95th-percentile displacement, and the folding rate (fraction of + sampled points with a non-positive Jacobian). + +*Keep per-phase segmentations* additionally stores every propagated mask +as a segmentation series on its phase (`4D `). + +Cancel stops the run at the next phase boundary; a finished run is never +applied to a dataset that was replaced while it ran. + +### Recipes — several studies, one workflow + +Starting a run remembers the dialog as a *recipe*: target and +reference-structure names, models, ITV options and registration settings. +*Apply last recipe* re-ticks the same structures **by name** in whatever +dataset the dialog is open on — load the next patient (or the paired +upright/supine study into dataset B), open the tool, apply, run. Recipes +are name-based on purpose: indices and UIDs do not travel between patients. + +## Results (`src/app/motion_results.rs`) + +The results window opens when a run finishes (later: *Tools ▸ 📈 Motion +results…*). Per run: the displacement-magnitude-vs-phase chart (targets × +models, reference structure dashed red), peak-to-peak amplitude and drift +bars, the per-phase table (|d| and volume per track), correlation lines +(r, p, significance stars, synchrony wording), registration-QA lines and +ITV volumes. + +**Compare with** puts a second run beside the first — dataset A vs. B, +upright vs. supine — matching ITVs and tracks *by target name and model*: +ITV volumes with percentage change, peak-to-peak amplitudes, side by side. + +**Export CSV** writes one long-format CSV (a `table` column separates the +sections: per-phase centroids and displacements, peak-to-peak rows, +correlations, QA, ITVs); a comparison appends the second run's rows. + +## Transfer by relationship (`src/app/transfer_win.rs`) + +*Tools ▸ ◎ Transfer by relationship…* places a structure of one dataset +into the other at the same **offset from a reference structure's +centroid** — the STAR workflow's target–heart relationship: a target is +projected into a dataset registration cannot reach (another patient, +another posture) via anatomy both datasets can segment. The target keeps +its shape; the tool reports the offset (RL / AP / SI) it applied. +Reference structures whose name contains "heart" are pre-picked. + +## Compare structures (`src/app/compare_win.rs`) + +*Tools ▸ ◑ Compare structures…* computes, for any two structures (either +dataset, contours or segmentations, different lattices): volumes, centroid +offset (vector and magnitude), Dice, 95th-percentile symmetric Hausdorff +distance and mean symmetric surface distance. The second mask is resampled +onto the first's lattice through patient coordinates; across two frames of +reference the window notes the comparison assumes corresponding coordinates. + +## Numerics worth knowing + +- Centroids are exact under the affine lattice→patient map (mean index, + then map); peak-to-peak is the largest pairwise distance, independent of + the reference-phase choice. +- The p-values come from the regularized incomplete beta function + (continued-fraction evaluation) — the exact t-distribution tail, not a + normal approximation; with 10 phases n is small enough for that to matter. +- HD95/MSD use the exact anisotropic Euclidean distance transform + (`morphology::dist2_to_foreground`) evaluated on surface voxels of each + mask against the other, both directions pooled for the percentile. +- ITV volumes inherit every caveat of nearest-neighbour resampling between + phase lattices; centroid metrics are the primary motion descriptors, as + in the underlying study design. diff --git a/docs/pacs.md b/docs/pacs.md new file mode 100644 index 0000000..1c9fcc0 --- /dev/null +++ b/docs/pacs.md @@ -0,0 +1,126 @@ +# The patient archive + +*Tools ▶ 🏥 PACS — patient archive…* opens the application's own store of DICOM +studies: every patient ever filed into it, listed in one window. + +It is a PACS in the sense that matters at a workstation — a persistent place +where patients live between sessions — not in the sense of DICOM networking: +no listener, no association negotiation, no C-FIND, C-MOVE or C-STORE. The +archive is a folder on disk that this application owns. + +## The three gestures + +| | | +|---|---| +| **📥 Import folder…** | Copy every DICOM file under a folder into the archive, filed by patient and study | +| **📩 Load into dataset A / B** | Read the selected patient or study into a viewer dataset | +| **📤 Send dataset A / B** | Write that dataset's structure sets and segmentation series back into the archive, attached to the study they belong to | + +The patient list shows one row per patient — `Doe John (P0001) 3 study(ies) +· 642 file(s)` — expanding into its studies, newest first, each as +`20260827 — Planning · CT, RTSTRUCT, SEG · 214 files`. A patient row means +the whole patient, a study row that study; right-click removes either from +the archive. + +The archive folder is shown at the top and can be pointed anywhere. It +defaults to `archive` inside the platform data directory — the same place the +downloaded model weights live (`%LOCALAPPDATA%\RustDICOMStation` on Windows, +`~/.local/share/RustDICOMStation` on Linux, `~/Library/Application +Support/RustDICOMStation` on macOS) — and is remembered in +`viewer_settings.txt` under `archive_dir`. + +## Layout + +```text +/ + / PATIENT.txt name, id + / STUDY.txt uid, date, description, modalities, files + .dcm +``` + +Nothing here is a database: the folder names are the DICOM identifiers, the +files keep their own Instance UIDs as names, and the two sidecars are plain +`key = value` text in the same shape as the settings file. Anyone can browse +the archive with a file manager, copy a study folder onto a stick, or hand it +to another DICOM application, and nothing is lost. + +The sidecars exist so that listing stays instant however large the archive +grows — reading headers out of ten thousand files is not. They are a cache, +never the truth: a study folder that arrived without one — copied in by hand — +has it rebuilt from the headers the first time it is listed, and deleting +every sidecar rebuilds the whole archive. + +Only the patient folder name comes from free text and so is sanitized: +anything outside ASCII letters, digits, `.`, `-` and `_` becomes `_`, capped +at 96 characters. That can map two identifiers onto one folder, so the folder +name is never the authority; `PATIENT.txt` is. + +## Filing + +Import copies; it never moves. Each file is opened as far as the pixel data +(not into it), and its Patient ID, Study Instance UID and SOP Instance UID +decide where it lands. A file already stored under the same SOP Instance UID +in the same study is left alone, so importing the same folder twice is a no-op +and re-importing a folder that has grown files only the new ones. Anything +that will not open as DICOM is counted as skipped and reported, not treated as +an error. Sidecars are rewritten once per touched study at the end — the +counts and the modality list are only right once everything is in. + +## Taking a patient into the viewer + +**A study folder in the archive is a DICOM folder**, so +*Load into dataset A / B* runs it through the same `loader::load_directory` +as *File ▶ Add DICOM folder*, with the same classification, the same +patient ▶ study ▶ series tree and the same merging into whatever the dataset +already holds. Selecting the patient row loads all of their studies at once. + +## Sending changes back + +*Send dataset A / B* writes back **derived objects only** — the structure sets +and the segmentation series. The images are already in the archive; re-sending +them would duplicate hundreds of megabytes under new Instance UIDs. + +Each written object gets + +* a **fresh SOP Instance UID and Series Instance UID** — a new object, not a + replacement, which keeps the archive append-only and a mistaken upload + harmless; +* the **original Study Instance UID**, from the object itself where it says so + and from the series it references otherwise, filing it under the patient and + study it belongs to; +* the **original Frame of Reference UID**, so the contours and masks still sit + on the images they were drawn on; +* a **reference to the image series** it was drawn on. + +The objects are written to a scratch folder (removed afterwards) and imported +through the ordinary import path, so a half-failed upload leaves the archive +untouched. Every send creates new instances, so sending twice leaves two +structure sets rather than overwriting one; the unwanted one can be removed +through the data tree, or the study's older objects from the archive. + +## What it is not + +* **Not a DICOM network node.** No SCP, no SCU, no AE titles. Files move by + the file system. +* **Not multi-user.** One application owns the folder. Two instances pointed + at the same archive will not corrupt it — files are written under unique + UIDs — but their listings can go stale until rescanned. +* **Not an anonymizer.** What goes in is what comes out. *Tools ▶ Anonymize* + ([export-and-tools.md](export-and-tools.md)) is the pass to run before + filing anything that must leave the department. + +## Verification + +`tests/archive.rs` runs the whole round trip on the synthetic phantom study +([example-data.md](example-data.md)): generate, file, list from the sidecars +alone, load the archived folder back through the ordinary loader, draw a +segmentation, send the derived objects back, and assert they joined the same +patient and study — no second patient or study, the Study Instance UID +unchanged, the file count grown by exactly the objects written, `SEG` now +among the study's modalities, the segmentation present on reload. Removing the +patient empties the archive. + +Unit tests in `src/archive.rs` cover the parts with no round trip: the +folder-name sanitizer against what acquiring systems actually write, a missing +root reading as an empty archive, a study reading back from its sidecar, and +`remove` refusing any path outside the archive root. diff --git a/docs/propagation.md b/docs/propagation.md index 883637c..e706051 100644 --- a/docs/propagation.md +++ b/docs/propagation.md @@ -1,82 +1,62 @@ # Contour and segmentation propagation -Once two datasets are aligned, the alignment is only half the answer: the -contours drawn on one of them have to arrive on the other. *Tools ▶ ⇄ -Propagate structures* carries any RTSTRUCT ROI or painted segmentation -across the active registration and lands it as an ordinary, editable -segmentation on the other dataset — convertible back to RTSTRUCT like any -other, and exportable as DICOM. +*Tools ▶ ⇄ Propagate structures* carries any RTSTRUCT ROI or painted +segmentation across the active registration and lands it on the other +dataset as an ordinary, editable segmentation — convertible back to +RTSTRUCT and exportable as DICOM. ## What it does * **Pull, never push.** Every voxel of the *destination* is asked where it - comes from, rather than every voxel of the source being asked where it - goes. Pushing a deformed mask forward leaves holes wherever the - deformation expands and double-writes wherever it compresses; pulling asks - one question per destination voxel and answers it exactly. + comes from; pushing a deformed mask forward would leave holes where the + deformation expands and double-write where it compresses. * **Sub-voxel boundaries.** The source mask is sampled trilinearly and - thresholded at ½, so the boundary lands where the contour really is rather - than on the nearest voxel centre. Structures cross between grids of - different spacing and orientation without any special case. + thresholded at ½: the boundary lands where the contour really is, and + structures cross grids of different spacing and orientation. * **Either direction.** The transform maps fixed → moving, so propagating - *onto* the moving dataset runs through its inverse. The window works this - out from the registration's own direction; you only choose which dataset - the structures come from. -* **A cached mapping.** The inverse of a deformable transform is a - fixed-point iteration — twelve evaluations of a control lattice per point. - Asked once per voxel of a 512³ study that is billions of operations for a - mapping that is smooth to well under a millimetre over any few voxels. So - the mapping is evaluated on a 3 mm lattice across the destination bounding - box and interpolated in between: exact for a rigid transform (the map is - affine, and so is the interpolation) and far below the contour's own - accuracy for a deformable one. + *onto* the moving dataset runs through its inverse; you only choose the + source dataset. +* **A cached mapping.** A deformable inverse is a fixed-point iteration, + twelve control-lattice evaluations per point — billions of operations + over a 512³ study. So the mapping is evaluated on a 3 mm lattice across + the destination bounding box and interpolated in between: exact for a + rigid transform, far below the contour's accuracy for a deformable one. Each propagated structure is reported as `name: 164.2 cm³ ▶ 170.1 cm³ -(+3.6 %)`. That volume change is the deformation's doing, and it is exactly -what the Jacobian statistics in the registration panel describe — the two -numbers are the same fact seen from two directions, and disagreeing with -each other is a good reason to look harder at the registration. +(+3.6 %)` — the volume change the registration panel's Jacobian statistics +also describe; if the two disagree, look harder at the registration. ## Global and local -**Globally**, propagation uses whatever registration is active. If that -registration was itself restricted to a region, the structures inside it get -the local mapping and everything else gets the global one — a refinement is -stored as the global warp *plus* a correction that is exactly zero outside -its lattice, so this needs no special handling. +**Globally**, propagation uses whatever registration is active; one +restricted to a region gives structures inside it the local mapping and +everything else the global one. -**Locally** — when one structure sits inside another and the enclosing one -is what actually deformed — the window's *Refine locally first* section runs -a local deformable refinement on the enclosing structure before anything is -carried. A small structure otherwise lands where the *larger* structure's -average deformation puts it, which is the classic failure mode of -propagating a tumour through a whole-thorax registration. +**Locally** — when one structure sits inside another that actually +deformed — *Refine locally first* runs a local deformable refinement on +the enclosing structure before anything is carried; otherwise a small +structure lands where the *larger* one's average deformation puts it. The refinement replaces the active registration, so the sidebar reports -exactly what the propagation used rather than something that happened -invisibly. Its method and parameters are the ones chosen in the sidebar -(forced to a deformable method — a rigid one would replace the alignment -instead of refining it), and its margin is set in the propagation window. +exactly what the propagation used; method and parameters come from the +sidebar (forced to deformable), the margin from the propagation window. ## Using it 1. Register the two datasets (any method; see [registration.md](registration.md)). 2. *Tools ▶ ⇄ Propagate structures…* -3. Choose the source dataset, tick the structures and segmentations to - carry, optionally pick an enclosing region to refine on, and press - **▶ Propagate**. +3. Choose the source dataset, tick what to carry, optionally pick an + enclosing region to refine on, and press **▶ Propagate**. -Results arrive named ` (from A)`, in the source structure's own -colour, as the active segmentation of the destination dataset. From there -they behave like anything else painted by hand: edit with the brush, view in -3D, convert to RTSTRUCT, export as DICOM. +Results arrive named ` (from A)`, in the source structure's +colour, as the destination's active segmentation — edit with the brush, +view in 3D, convert to RTSTRUCT, export as DICOM. ## Verification -`src/propagate.rs`'s unit tests assert that a translation carries a ball by -exactly that much (centroid within 0.5 mm, volume preserved to 6 %), that -the direction flag really reverses the mapping, that a structure mapped -outside the destination comes back *empty* rather than wrong, and that a -structure crosses between a 2 mm and a 3 mm grid with its volume intact to -10 %. +`src/propagate.rs`'s unit tests assert that a translation carries a ball +by exactly that much (centroid within 0.5 mm, volume preserved to 6 %), +that the direction flag really reverses the mapping, that a structure +mapped outside the destination comes back *empty*, and that a structure +crosses between a 2 mm and a 3 mm grid with its volume intact to 10 %. diff --git a/docs/registration.md b/docs/registration.md index ce7537c..32cf636 100644 --- a/docs/registration.md +++ b/docs/registration.md @@ -1,11 +1,10 @@ # Image registration -Intensity-based and landmark-based registration between the two loaded -datasets, with three independent engines, per-run analytics, a deformation -vector field you can see and export, and the option to restrict any of it to -a single structure. elastix and plastimatch are C++/ITK toolboxes; nothing of -either is linked here — the algorithms are **re-implemented natively in -Rust** to keep the application single-language. +Intensity- and landmark-based registration between the two loaded datasets: +three independent engines, per-run analytics, a deformation vector field +you can see and export, and the option to restrict any of it to a single +structure. elastix and plastimatch are C++/ITK toolboxes; nothing of either +is linked — the algorithms are **re-implemented natively in Rust**. ![registration](screenshot_registration.png) @@ -28,78 +27,63 @@ anatomy in gray, residual respiratory mismatch as magenta/green fringes.* ### elastix — stochastic sampling and ASGD -A native re-implementation of what an elastix parameter file with -`Optimizer AdaptiveStochasticGradientDescent`, `ImageSampler -RandomCoordinate`, `NewSamplesEveryIteration true` and `Metric -AdvancedMeanSquares` asks for — the toolbox's own defaults. +A native re-implementation of elastix's own defaults — `Optimizer +AdaptiveStochasticGradientDescent`, `ImageSampler RandomCoordinate`, +`NewSamplesEveryIteration true` and `Metric AdvancedMeanSquares` — and the +engine to reach for first: iterations are cheap, so thousands are +affordable, and the estimate's noise carries the search past small local +minima. * **Multi-resolution Gaussian pyramids** (`NumberOfResolutions`, default 3): - repeated [1 2 1]/4 smoothing + factor-2 decimation with correct - voxel-centre origin bookkeeping. -* **Random coordinate sampling** with fresh samples every iteration - (`NumberOfSpatialSamples`, default 3000), restricted to a body mask from a - configurable HU threshold (default −500) and, for a local run, to the - region. Sampling draws from a pre-built eligible-voxel list rather than - rejecting random draws, so every draw is a hit. + [1 2 1]/4 smoothing + factor-2 decimation, voxel-centre origin bookkeeping. +* **Random coordinate sampling**, fresh every iteration + (`NumberOfSpatialSamples`, default 3000), within a body mask from a + configurable HU threshold (default −500) and, for a local run, the region; + drawn from a pre-built eligible-voxel list, so every draw is a hit. * **Metric:** mean squared difference with analytic gradients. * **Optimizer:** Adaptive Stochastic Gradient Descent (Klein et al., IJCV 2009 — elastix's default) with automatic gain estimation, the sigmoid time-adaptation rule and a trust-region step cap. * **Rigid:** 6-DOF Euler transform about the fixed-image centre, with automatic rotation/translation parameter scaling. -* **Deformable:** the recovered rigid transform composed with a cubic - B-spline free-form deformation (`FinalGridSpacingInPhysicalUnits`, default - 32 mm), optimized coarse-to-fine across the pyramid. - -The defining property is that an iteration costs almost nothing, so -thousands are affordable, and the noise in the estimate is what carries the -search past small local minima. It is the engine to reach for first. +* **Deformable:** the rigid result composed with a cubic B-spline free-form + deformation (`FinalGridSpacingInPhysicalUnits`, default 32 mm), optimized + coarse-to-fine across the pyramid. ### plastimatch — a dense exact gradient and L-BFGS Following plastimatch's `bspline` (Shackleford et al., *High performance -deformable image registration algorithms for manycore processors*), and the +deformable image registration algorithms for manycore processors*) — the opposite trade: far more work per iteration, far fewer of them. 1. `xform=align_center` — a translation matching the centres of gravity of - the two thresholded images, which removes the gross offset a deformable - model should never have to represent. Skipped for a local run or a - refinement, both of which already start from an alignment. -2. `xform=bspline` per resolution level, coarse to fine. The cost and its - **exact analytic gradient** are evaluated over every eligible fixed - voxel and each voxel's contribution is scattered onto the 64 control - points that support it. - -* **Metric.** `mse` is the mean squared difference, divided by the fixed - image's variance so the cost is dimensionless. `mi` is **Mattes mutual - information** over a 32 × 32 joint histogram with a zero-order Parzen - window on the fixed image and a cubic B-spline window on the moving one - (Mattes et al., IEEE TMI 2003) — the only metric here that survives two - modalities, and therefore the CT–MR and CT–CBCT option. Both metrics share - one gradient scatter loop: only the per-sample scalar differs. + the two thresholded images. Skipped for a local run or a refinement, + which already start aligned. +2. `xform=bspline` per resolution level, coarse to fine: the cost and its + **exact analytic gradient** over every eligible fixed voxel, each voxel's + contribution scattered onto the 64 control points that support it. + +* **Metric.** `mse` is the mean squared difference divided by the fixed + image's variance, so the cost is dimensionless. `mi` is **Mattes mutual + information** over a 32 × 32 joint histogram, zero-order Parzen window on + the fixed image and cubic B-spline window on the moving one (Mattes et + al., IEEE TMI 2003) — the multi-modal (CT–MR, CT–CBCT) option. * **Regularizer.** `young_modulus` weights the discrete bending energy of the control lattice (second differences, mixed terms counted twice), made - dimensionless by the lattice spacing so the same weight means the same - smoothing at any grid size. It is quadratic in the coefficients, so its - gradient is exact — a unit test checks it against a central difference. + dimensionless by the lattice spacing; its gradient is exact. * **Optimizer.** L-BFGS (two-loop recursion, history 6) with an Armijo - backtracking line search. plastimatch's default is L-BFGS-B; the bounded - variant differs only in handling box constraints, and B-spline - coefficients have none. + backtracking line search; plastimatch's default L-BFGS-B differs only in + box constraints, which B-spline coefficients lack. -"Dense" is capped at 400 000 samples per level — on a 512³ study every -eligible voxel is tens of millions, and an exact gradient over all of them is -not what anybody wants to wait for. The thinning is deterministic, so the -sample set is the same on every iteration, which is what keeps the engine's -character. +"Dense" is capped at 400 000 samples per level, thinned deterministically +so the set is the same on every iteration — on a 512³ study every eligible +voxel is tens of millions. ### plastimatch — landmark warp -`landmark_warp`: a deformation that interpolates paired points, with no image -intensity read at any stage. That is what is wanted when the two images have -nothing an intensity metric can lock onto (CT against MR, a post-operative -cavity, anatomy that genuinely changed) or when the alignment must honour -specific anatomical points and nothing else. +`landmark_warp` interpolates paired points and reads no image intensity — +for CT against MR, a post-operative cavity, anatomy that genuinely changed, +or when specific anatomical points must be honoured and nothing else. | kernel | φ(r) | support | affine term | |---|---|---|---| @@ -107,34 +91,31 @@ specific anatomical points and nothing else. | Gaussian | `exp(−r² / 2R²)` | global, decaying | no | | Wendland ψ₃,₁ | `(1 − r/R)⁴ (4r/R + 1)` | compact, zero beyond `R` | no | -The thin-plate spline minimizes bending energy over the whole domain and -carries an affine term, so a global shift or rotation implied by the -landmarks is reproduced exactly — it needs at least four non-coplanar pairs. -The two radial kernels have no affine term, so the displacement decays back -to zero away from the landmarks; the compactly supported Wendland kernel is -the one that *provably* leaves distant anatomy untouched. `stiffness` -(plastimatch's regularization) is added to the diagonal of the interpolation -matrix: zero passes exactly through every landmark, larger values smooth the -field and tolerate inconsistent pairs. - -Landmarks are placed from the interface: put the crosshair on the same -anatomy in both datasets and press **➕ Add pair** in the *Landmarks* section -(turn *View ▶ Sync crosshairs* off first, or both crosshairs move together). -Each pair shows its displacement, and after a run, its residual. +The thin-plate spline minimizes bending energy over the whole domain; its +affine term reproduces exactly any global shift or rotation implied by the +landmarks, and it needs at least four non-coplanar pairs. The radial kernels +have no affine term, so displacement decays to zero away from the landmarks; +the compactly supported Wendland kernel *provably* leaves distant anatomy +untouched. `stiffness` (plastimatch's regularization) is added to the +diagonal of the interpolation matrix: zero passes exactly through every +landmark, larger values smooth the field and tolerate inconsistent pairs. + +Put the crosshair on the same anatomy in both datasets and press +**➕ Add pair** in the *Landmarks* section (with *View ▶ Sync crosshairs* +off, or both crosshairs move together). Each pair shows its displacement +and, after a run, its residual. ## Running a registration -*Modules ▶ Image registration* puts the section in the left panel. With -two datasets loaded it registers one onto the other — the direction is selectable (**B ▶ A** or -**A ▶ B**; the second-named dataset is the fixed image and receives the -fusion overlay). Everything lives in that one section: method, region, -parameters, landmarks, the result and the vector field. Runs happen on a +*Modules ▶ Image registration* puts the section — method, region, +parameters, landmarks, result and vector field — in the left panel. With +two datasets loaded it registers one onto the other, **B ▶ A** or **A ▶ B** +(the second-named is the fixed image and receives the fusion overlay), on a background thread with progress and a **Cancel** button. -The transform convention is **fixed → moving** patient coordinates, as in -elastix, ITK and plastimatch alike; the inverse (needed for the crosshair -link and for propagation) is exact for the rigid part and a fixed-point -iteration for the deformable one. +The transform maps **fixed → moving** patient coordinates, as in elastix, +ITK and plastimatch; the inverse (for the crosshair link and propagation) is +exact for the rigid part and a fixed-point iteration for the deformable one. On the bundled data (512 × 512 × 133 CT, two breathing phases): elastix rigid pre-alignment plus three B-spline resolution levels, 1800 iterations @@ -144,142 +125,118 @@ total, ≈ 20 s on a desktop CPU, driving the mean-squared HU difference from ## Local registration Any method can be restricted to a **region** — an RTSTRUCT ROI or a painted -segmentation of the fixed dataset, dilated by a margin. That is what -"register this tumour, not the whole patient" means, and it changes three -things: +segmentation of the fixed dataset, dilated by a margin. Three things change: * samples come from inside the region only; -* the B-spline control lattice covers the region's bounding box rather than - the volume, so a small structure can be aligned at a fine grid spacing - that would be unaffordable globally; -* the centre of rotation and the parameter scaling are the region's, not the - patient's — rotating a tumour about the patient's centre would put the - whole recovered angle into the translation. +* the B-spline control lattice covers the region's bounding box, so a small + structure can be aligned at a grid spacing unaffordable globally; +* the centre of rotation and parameter scaling are the region's, not the + patient's. A **local deformable** run skips the rigid stage on purpose: a rigid body -fitted to one structure would be applied to the whole volume and move -anatomy nobody asked about. Confined to its lattice, the correction is -exactly zero outside the region — the integration test asserts this to -machine precision. A **local rigid** run is different by nature: it reports -how that structure moved *as a rigid body*, and the transform is global. - -The margin matters. Without it nothing outside the structure constrains its -boundary, and the boundary is what you are aligning. +fitted to one structure would move the whole volume. Confined to its +lattice, the correction is exactly zero outside the region. A **local +rigid** run instead reports how that structure moved *as a rigid body*; the +transform is global. Without a margin nothing outside the structure +constrains the boundary being aligned. ### Refining -**▶ Refine** recovers a correction *on top of* the active registration -instead of replacing it: the moving image is sampled through the existing -transform plus the new deformation, and the result is the two composed. The -intended workflow is a global registration first, then a local refinement on -the structure that matters — after which the rest of the patient still -carries the global result, unchanged. +**▶ Refine** recovers a correction *on top of* the active registration: the +moving image is sampled through the existing transform plus the new +deformation, and the result is the two composed — typically a global +registration, then a local refinement on the structure that matters, leaving +the rest of the patient on the global result. ## What the result says -The result block reports the method, the region if any, the metric before -and after, and the deformation model. The **Analysis** section is measured -on the transform itself, on a lattice over the fixed image (or the region), -so it means the same thing for every method: - -* **Best-fitting rigid body** — the orthogonal Procrustes fit of the - mapping: translation, three Euler angles in the same `Rz Ry Rx` convention - as the rigid transform, and the RMS residual, which says how much of the - transform those six numbers do *not* explain (zero for a rigid result, by - construction). Usually the first number a physicist wants: how far did the - patient move, and how far did they turn? +The result block reports method, region if any, metric before and after, and +deformation model. The **Analysis** section is measured on the transform +itself, on a lattice over the fixed image (or the region), so it means the +same for every method: + +* **Best-fitting rigid body** — the orthogonal Procrustes fit: translation, + three Euler angles in the same `Rz Ry Rx` convention as the rigid + transform, and the RMS residual those six numbers do *not* explain. * **Displacements** — min / mean / p95 / max / RMS of `|T(p) − p|` in - millimetres, plus the mean *vector*, which separates a systematic shift - from scattered local motion. + millimetres, plus the mean *vector* (systematic shift vs. scattered local + motion). * **Jacobian determinant** — `det(I + ∂d/∂x)` by central differences: above - 1 the tissue expanded, below 1 it compressed, and at or below zero the - deformation folded onto itself, which is not anatomy but an artefact. The - folded fraction is reported explicitly; a regularized B-spline should show - none. -* **Per structure** — the mean and maximum displacement over each - contoured structure's own points, which is what turns "the registration - moved things by 4 mm on average" into "the tumour moved 9 mm and the cord - 0.4 mm". + 1 the tissue expanded, below 1 it compressed, at or below zero it folded. + The folded fraction is reported; a regularized B-spline should show none. +* **Per structure** — mean and maximum displacement over each contoured + structure's own points: "the tumour moved 9 mm and the cord 0.4 mm" + rather than "4 mm on average". ## The fusion overlay and the vector field -**Fusion** blends the transformed moving image into the green channel of the -fixed image, so aligned anatomy reads gray and mismatch reads magenta/green, -with a blend slider. The cross-study crosshair link maps through the -recovered transform (inverse included), so clicking a point in either study -lands on the same anatomy in the other. +**Fusion** blends the transformed moving image into the fixed image's green +channel (aligned anatomy gray, mismatch magenta/green) with a blend slider; +the cross-study crosshair link maps through the recovered transform, inverse +included. The **vector field** is the transform sampled onto a regular lattice — once, -rather than per pixel on every repaint, because a B-spline evaluation is 64 -weighted lookups and a landmark warp is a sum over every landmark. It is -drawn in all three MPR views of the fixed dataset and, optionally, in the 3D -window: - -* **Arrows** from where the anatomy is to where it goes, at an adjustable - exaggeration (millimetre motion is invisible at 1×) and coloured by - magnitude. Displacement that leaves the view plane cannot be drawn as an - arrow, so it is drawn as a disc whose size is the out-of-plane component. -* **Deformed grid** — the sampling lattice pushed through the deformation, - the classic warped graph paper: arrows show motion, a deformed grid shows - compression and expansion. +not per pixel on every repaint: a B-spline evaluation is 64 weighted +lookups, a landmark warp a sum over every landmark. It is drawn in all three +MPR views of the fixed dataset and, optionally, in the 3D window: + +* **Arrows** from where anatomy is to where it goes, exaggerated by an + adjustable factor (millimetre motion is invisible at 1×) and coloured by + magnitude; out-of-plane displacement becomes a disc sized by that component. +* **Deformed grid** — the sampling lattice pushed through the deformation: + warped graph paper, showing compression and expansion. * Lattice spacing, arrow scale and colouring are adjustable; changing the spacing re-samples on a worker thread. In the **3D window**, *Dataset B through the registration* meshes the other dataset's structures and maps every vertex through the recovered transform, -so both anatomies stand in one frame of reference with an independent -opacity each — the only way to see what a deformable registration actually -did to a surface. The field can be overlaid as 3-D arrows in the same scene. +so both anatomies stand in one frame of reference with independent +opacities. The field can be overlaid as 3-D arrows in the same scene. ## DICOM interchange -A rigid matrix from a DICOM **REG** object can be applied instead of running -the optimizer, and a **Deformable Spatial Registration** object's -displacement grid can be applied the same way — it becomes the active -registration and everything downstream (fusion, crosshair link, analytics, -propagation) works on it without knowing where it came from. See +A rigid matrix from a DICOM **REG** object or a **Deformable Spatial +Registration** object's displacement grid can be applied instead of running +the optimizer; it becomes the active registration and everything downstream +(fusion, crosshair link, analytics, propagation) works on it. See [rt-objects.md](rt-objects.md). -**💾 Save as DICOM…** writes the active field back out as a Deformable -Spatial Registration. The IOD applies its grid after a pre-deformation -matrix and before a post-deformation one; both are written as the identity -and the grid carries the whole mapping, `T(p) − p`, so another system has no -composition rule to get wrong. +**💾 Save as DICOM…** writes the active field out as a Deformable Spatial +Registration. The IOD applies its grid between a pre- and a post-deformation +matrix; both are written as the identity and the grid carries the whole +mapping, `T(p) − p`. ## Propagating structures -Once the datasets are aligned, the contours drawn on one of them can be -carried to the other — see [propagation.md](propagation.md). +Once aligned, contours drawn on one dataset can be carried to the other — +see [propagation.md](propagation.md). ## Transform simulator (registration QA) -The *Simulation* sidebar section applies an **exactly known** transform to a -loaded dataset — rigid motion (translation + Euler rotation about the volume -centre) plus an optional local Gaussian deformation (amplitude vector + σ, -centred at the crosshair) — and generates the result into the other dataset -slot: the CT is resampled through the inverse transform, and structure -contours, dose grids and plan isocentres are carried along. The applied -parameters stay displayed as ground truth, so you can immediately run any -engine and compare the recovered transform against it. Any dataset — -original or simulated — can then be exported as DICOM (see -[export-and-tools.md](export-and-tools.md)). +The *Simulation* sidebar section applies an **exactly known** transform — +rigid motion (translation + Euler rotation about the volume centre) plus an +optional local Gaussian deformation (amplitude vector + σ, centred at the +crosshair) — to a loaded dataset and generates the result into the other +slot: the CT is resampled through the inverse transform; structure contours, +dose grids and plan isocentres are carried along. The applied parameters +stay displayed as ground truth. Any dataset, original or simulated, can then +be exported as DICOM (see [export-and-tools.md](export-and-tools.md)). ## Accuracy verification `tests/registration.rs` registers analytically known transforms on a -synthetic phantom, with the same tolerances applied to every engine: +synthetic phantom, with the same tolerances for every engine: * **elastix rigid** recovers a known rotation + translation to ≈ 0.6 mm - (asserted 1.5 mm), the inverse round-trips to 10⁻⁶ mm, and the six-DOF - analysis reproduces the recovered transform to 10⁻³ degrees with zero - residual and unit Jacobian. + (asserted 1.5 mm); the inverse round-trips to 10⁻⁶ mm; the six-DOF + analysis reproduces it to 10⁻³ degrees, zero residual, unit Jacobian. * **elastix B-spline** and **plastimatch B-spline** each recover a 7 mm Gaussian-bump deformation to ≈ 0.3 mm (asserted 3 mm), with no folding. * **plastimatch mutual information** recovers the same bump between images - whose soft-tissue contrast has been *inverted* — a case where mean squares - has no minimum at the truth at all. + with *inverted* soft-tissue contrast, where mean squares has no minimum + at the truth at all. * **landmark warp** lands on every landmark to 10⁻⁴ mm with all three - kernels, the thin-plate spline reproduces a global shift everywhere + kernels; the thin-plate spline reproduces a global shift everywhere, including far outside the landmark hull, and the Wendland kernel leaves points beyond its radius at exactly zero. * **local registration** recovers a displacement applied inside one blob and @@ -288,16 +245,16 @@ synthetic phantom, with the same tolerances applied to every engine: * **the vector field** reproduces the transform it was sampled from to < 0.05 mm. -Unit tests cover the pieces the integration tests can only see through: the -Parzen window and its derivative against finite differences, the bending -energy's gradient against a central difference and its vanishing on an -affine field, the Procrustes fit against a reflection, region dilation by an -exact margin, and the local lattice's coverage of its region. +Unit tests check the Parzen window and its derivative against finite +differences, the bending energy's gradient against a central difference and +its vanishing on an affine field, the Procrustes fit against a reflection, +region dilation by an exact margin, and the local lattice's coverage of its +region. ## Notes Deformable results are intensity-driven: displacements inside large uniform regions are interpolated from the control lattice rather than measured — the -Jacobian and the per-structure displacements are how you tell the difference. -Mean squares assumes comparable intensities (CT–CT); for CT–MR use the -plastimatch engine with mutual information, or place landmarks. +Jacobian and per-structure displacements tell the difference. Mean squares +assumes comparable intensities (CT–CT); for CT–MR use the plastimatch engine +with mutual information, or place landmarks. diff --git a/docs/rt-objects.md b/docs/rt-objects.md index fed1b2c..c4777c3 100644 --- a/docs/rt-objects.md +++ b/docs/rt-objects.md @@ -1,121 +1,105 @@ # RT DICOM objects -The viewer loads a complete radiotherapy study: alongside the image series -it parses RT Structure Sets, DICOM Segmentation objects, RT Dose, RT Plans -(photon and ion/proton), Spatial Registration objects and RT treatment -records, and resolves the DICOM reference chains between them. +Alongside the image series the viewer parses the RT objects below and +resolves the DICOM reference chains between them. ## RTSTRUCT — structure sets -Parsed per file: ROI names, display colors, interpreted types (PTV, CTV, -GTV, ORGAN, EXTERNAL, …) and all planar contours in patient coordinates. -ROIs are ordered EXTERNAL → PTV → CTV → GTV → alphabetical, and a fallback -12-color palette fills in for structure sets without stored colors. - -Display: axial views draw the **native closed contours**; sagittal and -coronal views show the **reconstructed cross-section silhouette** of each -ROI (even–odd crossing pairing of the contour stack). Per-ROI visibility -toggles live in the sidebar, with All/None shortcuts. - -**Every** structure set found in the folder is loaded (e.g. one per 4DCT -phase) and selectable; the set that references the active image series -(RTReferencedSeriesSequence) is chosen automatically and follows series -switches. Structure sets also feed the 3D surface view — see +Parsed per file: ROI names, display colors, interpreted types (PTV, CTV, GTV, +ORGAN, EXTERNAL, …) and all planar contours in patient coordinates. ROIs are +ordered EXTERNAL → PTV → CTV → GTV → alphabetical, and a fallback 12-color +palette fills in for structure sets without stored colors. + +Axial views draw the **native closed contours**; sagittal and coronal views +show the **reconstructed cross-section silhouette** of each ROI (even–odd +crossing pairing of the contour stack). Per-ROI visibility toggles live in the +sidebar, with All/None shortcuts. **Every** structure set in the folder is +loaded (e.g. one per 4DCT phase) and selectable; the set referencing the +active image series (RTReferencedSeriesSequence) is chosen automatically and +follows series switches. Structure sets also feed the 3D surface view — see [segmentation.md](segmentation.md). ## SEG — DICOM Segmentation objects -A Segmentation instance is a multi-frame image whose frames are binary -masks, one per (segment, slice) pair, placed in patient space by the -per-frame functional groups rather than by a slice index. Reading one -therefore means rebuilding a lattice from the frame positions: the frames -are grouped into slice levels along the stack normal, the slice spacing is -the median level distance, and the in-plane geometry comes from -`PixelMeasuresSequence` / `PlaneOrientationSequence` (shared group first, -first per-frame group as a fallback). +A Segmentation instance is a multi-frame image of binary masks, one frame per +(segment, slice) pair, placed in patient space by the per-frame functional +groups rather than a slice index. Reading one rebuilds a lattice from the +frame positions: frames are grouped into slice levels along the stack normal, +the slice spacing is the median level distance, and the in-plane geometry +comes from `PixelMeasuresSequence` / `PlaneOrientationSequence` (shared group +first, first per-frame group as a fallback). Supported: `BINARY` (1 bit per pixel, packed across *all* frames as one continuous stream) and `FRACTIONAL` (8 bit, thresholded at half -`MaximumFractionalValue`). Segment labels come from `SegmentSequence`, and -segment colors from `RecommendedDisplayCIELabValue`, converted through -CIELab → XYZ (D65) → sRGB; segments without a stored color fall back to the -8-color segmentation palette. Compressed (encapsulated) Pixel Data is -reported as a load warning rather than guessed at. - -Each SEG file becomes one **segmentation series** in the data tree, linked -to the image series named in `ReferencedSeriesSequence`. The masks keep the -lattice they arrived on and are resampled onto the displayed volume only -when their own image series is the one being shown, so a study can carry -segmentations of several series at once without any of them being silently -reinterpreted on the wrong grid. - -Writing is the same shape in reverse: only the slices a segment actually -occupies become frames, so a ten-slice structure on a 200-slice CT costs -ten frames. See [export-and-tools.md](export-and-tools.md#dicom-export). +`MaximumFractionalValue`). Segment labels come from `SegmentSequence`, colors +from `RecommendedDisplayCIELabValue` via CIELab → XYZ (D65) → sRGB, with the +8-color segmentation palette as fallback. Compressed (encapsulated) Pixel Data +is reported as a load warning. + +Each SEG file becomes one **segmentation series** in the data tree, linked to +the image series named in `ReferencedSeriesSequence`. The masks keep their own +lattice and are resampled onto the displayed volume only when their own image +series is shown, so a study can carry segmentations of several series at once. +Writing is the reverse: only the slices a segment occupies become frames, so a +ten-slice structure on a 200-slice CT costs ten frames. See +[export-and-tools.md](export-and-tools.md#dicom-export). ## RTDOSE — dose grids 16- and 32-bit dose grids with `DoseGridScaling` applied at load, -`GridFrameOffsetVector` handled in full generality (uniform or not, -ascending or descending — descending grids are re-ordered), and the frame -offsets re-based onto ImagePositionPatient. Multiple dose files (plan -and/or per-beam doses) are listed and selectable. - -Sampling is trilinear in patient space: bilinear in-plane plus linear -across the (possibly non-uniform) frame offsets, with an incremental -affine fast path used when resampling a whole display plane. Display -offers: - -* a translucent **colorwash** with adjustable opacity and a lower - threshold (in % of the reference dose); +`GridFrameOffsetVector` handled in full generality (uniform or not, ascending +or descending — descending grids are re-ordered) and the frame offsets +re-based onto ImagePositionPatient. Multiple dose files (plan and/or per-beam) +are listed and selectable. + +Sampling is trilinear in patient space (bilinear in-plane, linear across the +possibly non-uniform frame offsets), with an incremental affine fast path when +resampling a whole display plane. Display offers: + +* a translucent **colorwash** with adjustable opacity and a lower threshold + (in % of the reference dose); * **isodose lines** at configurable percentages, extracted per level with marching squares (parallelized across levels). -The **reference dose** defaults to the prescription dose picked up from -the plan (`TargetPrescriptionDose`) and can be overridden. The status bar -shows Gy and % of reference at the crosshair for both datasets. +The **reference dose** defaults to the plan's `TargetPrescriptionDose` and can +be overridden; the status bar shows Gy and % of reference at the crosshair for +both datasets. ## RTPLAN — photon and ion plans Photon (`BeamSequence`) and ion/proton (`IonBeamSequence`) plans are -summarized: label, date, prescription and fractionation, and a per-beam -table with radiation type, delivery type, scan mode (for scanned ion -beams), gantry/couch angles, energy range, meterset and control-point -count. Beam isocenters are marked in all three views (toggleable). +summarized: label, date, prescription and fractionation, and a per-beam table +with radiation type, delivery type, scan mode (for scanned ion beams), +gantry/couch angles, energy range, meterset and control-point count. Beam +isocenters are marked in all three views (toggleable). ## REG — spatial registration objects -Rigid Spatial Registration files are parsed into their 4×4 -frame-of-reference transformation matrices, shown with the decomposed -translation/rotation and frame-of-reference hints (matched against the -loaded studies' FoR UIDs). A matrix can be **applied as the active -registration** in either direction, with an optional inversion — a -TPS-exported registration then immediately drives the fusion overlay and -the cross-study crosshair link without running the optimizer. The matrix is +Rigid Spatial Registration files are parsed into their 4×4 frame-of-reference +matrices, shown with the decomposed translation/rotation and +frame-of-reference hints (matched against the loaded studies' FoR UIDs). A +matrix can be **applied as the active registration** in either direction, +optionally inverted, so a TPS-exported registration drives the fusion overlay +and the cross-study crosshair link without running the optimizer; it is validated (orthonormality, no reflection/scale) before being accepted. **Deformable Spatial Registration** objects are read the same way, grid -included: the displacement lattice becomes a transform that can be applied -in either direction, after which everything downstream — fusion, the -crosshair link, the analytics, the vector-field display, structure -propagation — works on it without knowing where it came from. The panel -reports the lattice size, its spacing and its largest displacement, and says -which loaded dataset the grid's own frame of reference matches, so applying -it the wrong way round is a deliberate act rather than an accident. - -A registration recovered here can be written back out as a Deformable -Spatial Registration (*Image registration ▶ Vector field ▶ 💾 Save as DICOM…*). -The IOD applies its grid after a pre-deformation matrix and before a -post-deformation one; both are written as the identity and the grid carries -the whole mapping, so another system has no composition rule to get wrong. -See [registration.md](registration.md). +included: the displacement lattice becomes a transform applicable in either +direction, and everything downstream — fusion, the crosshair link, the +analytics, the vector-field display, structure propagation — works on it +unchanged. The panel reports the lattice size, spacing and largest +displacement, and which loaded dataset the grid's frame of reference matches. + +A registration recovered here can be written back out as a Deformable Spatial +Registration (*Image registration ▶ Vector field ▶ 💾 Save as DICOM…*); the +IOD's pre- and post-deformation matrices are written as the identity and the +grid carries the whole mapping. See [registration.md](registration.md). ## RTRECORD — treatment records -RT (Ion) Beams Treatment Records are summarized per session: fraction -number, date, machine, and a per-beam table of specified vs delivered -meterset with the percentage difference and the termination status -(non-NORMAL terminations highlighted). +RT (Ion) Beams Treatment Records are summarized per session: fraction number, +date, machine, and a per-beam table of specified vs delivered meterset with +percentage difference and termination status (non-NORMAL highlighted). ## Reference chains @@ -125,11 +109,10 @@ The viewer parses and preserves the standard chain CT series ◀ RTSTRUCT ◀ RTPLAN ◀ RTDOSE ``` -and uses it for: automatic selection of the structure set matching the -displayed series, pairing doses with their plans (and hence the -prescription dose), tree copy/move semantics (a series carries exactly its -dependent RT objects), and DICOM export (the chain is written back out). -Frame-of-Reference UIDs associate objects spatially; RT objects with a -different FoR still load and display, but patient-space overlays are only -meaningful within one frame of reference (or through an explicit -registration). +and uses it to select the structure set matching the displayed series, pair +doses with their plans (and hence the prescription dose), define tree +copy/move semantics (a series carries exactly its dependent RT objects) and +drive DICOM export (the chain is written back out). Frame-of-Reference UIDs +associate objects spatially; RT objects with a different FoR still load and +display, but patient-space overlays are only meaningful within one frame of +reference (or through an explicit registration). diff --git a/docs/segmentation.md b/docs/segmentation.md index 4498b31..39661b1 100644 --- a/docs/segmentation.md +++ b/docs/segmentation.md @@ -1,59 +1,56 @@ # Interactive segmentation and the 3D view -MITK-style manual and semi-automatic segmentation, implemented entirely in -Rust and CPU-side, plus a Slicer-style 3D surface view that follows every -edit in essentially real time. For the neural-network auto-segmentation -see [auto-segmentation.md](auto-segmentation.md), and for the patient -outline [body-contour.md](body-contour.md) — both land as the same editable -masks described here. +MITK-style manual and semi-automatic segmentation, entirely in Rust and +CPU-side, plus a Slicer-style 3D surface view. Neural-network +auto-segmentation is [auto-segmentation.md](auto-segmentation.md), the patient +outline [body-contour.md](body-contour.md) — both land as the editable masks +described here — and combining structures is +[structure-algebra.md](structure-algebra.md). ## Segmentation masks -A segmentation is a per-voxel label mask (one byte per voxel, same index -order as the volume), with a name, display color, visibility flag, voxel -count / volume readout and a per-stroke undo journal. - -Masks are grouped into **segmentation series**, which are what a DICOM SEG -file is and what one exports as. A series lives in the study — not in the -view state — and names the image series it is drawn on, so painted work -survives a series switch, travels with tree copy/move, and can be -re-pointed at a different image series later. Its masks keep the lattice -they were made on and are resampled onto the displayed volume when their -own image series is shown; a series belonging to another image series -stays intact and simply reports that it is not editable here. - -The sidebar *Segmentations* section shows the series as tree nodes and, -below the active one, its segments: visibility, color, active selection, -volume in cm³, per-stroke undo (**Ctrl+Z**), delete, and conversion to -RTSTRUCT (**→RS**, below). *All* / *None* tick every segment or none, -Shift-click extends a range, and the tick doubles as the selection that the -row of actions beneath it — copy, move, remove, and *💾* to export just -those segments as a DICOM SEG file — works on. See -[viewer.md](viewer.md#structures-and-segmentations-in-the-tree). +A segmentation is a per-voxel label mask (one byte per voxel, same index order +as the volume), with a name, display color, visibility flag, voxel count / +volume readout and a per-stroke undo journal. + +Masks are grouped into **segmentation series** — what a DICOM SEG file is and +exports as. A series lives in the study, not the view state, and names the +image series it is drawn on, so painted work survives a series switch, travels +with tree copy/move, and can be re-pointed at a different image series later. +Its masks keep the lattice they were made on and are resampled onto the +displayed volume when their own image series is shown; a series belonging to +another image series stays intact and simply reports that it is not editable +here. + +The sidebar *Segmentations* section shows the series as tree nodes and, below +the active one, its segments: visibility, color, active selection, volume in +cm³, per-stroke undo (**Ctrl+Z**), delete, and conversion to RTSTRUCT +(**→RS**, below). *All* / *None* tick every segment or none, Shift-click +extends a range, and the tick doubles as the selection for the actions beneath +— copy, move, remove, and *💾* to export just those segments as a DICOM SEG +file. See [viewer.md](viewer.md#structures-and-segmentations-in-the-tree). ## Tools The toolbar tools take over the left mouse button in the MPR views: -* **🖌 Paint / ◻ Erase** — a spherical, spacing-aware **3D brush** (radius - in mm, adjustable via the toolbar, `Shift+wheel` or `[` `]`) paints in - any of the three views; a **3D** toggle switches to a flat 2D circle - confined to the displayed slice. Strokes are swept as capsules between - pointer samples, so fast drags stay gap-free. `Alt` temporarily erases - while painting. +* **🖌 Paint / ◻ Erase** — a spherical, spacing-aware **3D brush** (radius in + mm, set via the toolbar, `Shift+wheel` or `[` `]`) paints in any of the + three views; a **3D** toggle switches to a flat 2D circle confined to the + displayed slice. Strokes are swept as capsules between pointer samples, so + fast drags stay gap-free; `Alt` temporarily erases while painting. * **✨ Grow** — interactive **organ-wise** segmentation by geodesic fast - marching (not a plain threshold): a Dijkstra front expands from the - seed, and the cost of each step rises exponentially with the voxel's - intensity deviation from robust seed statistics (median/MAD of the local - neighborhood) **and** with the intensity jump of the crossing itself — - organ boundaries, fat planes and edges act as barriers, so the organ - under the cursor is suggested first instead of flooding all - similar-intensity tissue. Press to seed, drag up/down to extend/shrink - the geodesic reach with a live yellow preview; the front expands - *incrementally* (drag up continues the same priority queue, drag down - truncates the accepted prefix), so the preview never recomputes from - scratch. Release commits — enclosed holes (vessels, calcifications) are - filled slice-wise so the organ comes out solid — and `Esc` cancels. + marching, not a plain threshold: a Dijkstra front expands from the seed, the + cost of each step rising exponentially with the voxel's intensity deviation + from robust seed statistics (median/MAD of the local neighborhood) **and** + with the intensity jump of the crossing itself — organ boundaries, fat + planes and edges act as barriers, so the organ under the cursor is suggested + first instead of all similar-intensity tissue. Press to seed, drag up/down + to extend/shrink the geodesic reach with a live yellow preview; the front + expands *incrementally* (drag up continues the same priority queue, drag + down truncates the accepted prefix), never recomputing from scratch. Release + commits — enclosed holes (vessels, calcifications) are filled slice-wise so + the organ comes out solid — and `Esc` cancels. | Input (tool active) | Action | |---|---| @@ -68,36 +65,34 @@ colorwash over the grayscale). ## The 3D structure view -The **3D A / 3D B** toolbar buttons open a floating window with a 3D -surface rendering of the active structure set **and** all segmentation -masks. Surfaces are reconstructed on a background thread — scanline -rasterization of contours into a binary volume, a surface-nets mesher, -Laplacian smoothing, area-weighted vertex normals, `rayon`-parallel per -ROI — and drawn in the display colors with headlight shading; -EXTERNAL/body ROIs are rendered translucent so internal anatomy stays -visible. +The **3D A / 3D B** toolbar buttons open a floating window with a 3D surface +rendering of the active structure set **and** all segmentation masks. Surfaces +are built on a background thread — scanline rasterization of contours into a +binary volume, a surface-nets mesher, Laplacian smoothing, area-weighted +vertex normals, `rayon`-parallel per ROI — and drawn in the display colors +with headlight shading; EXTERNAL/body ROIs are translucent so internal anatomy +stays visible. Every mask edit re-meshes that segmentation in the background -(bounding-box-cropped surface nets with automatic striding for huge -masks), so the 3D surface follows the brush in essentially real time. -Meshes are cached per structure set, so reopening the window is instant. -Drag rotates, the wheel zooms, middle-drag pans, a slider controls global -opacity, and *⟲ Reset view* restores the default camera. +(bounding-box-cropped surface nets, automatic striding for huge masks), so the +3D surface follows the brush in essentially real time; meshes are cached per +structure set, so reopening the window is instant. Drag rotates, the wheel +zooms, middle-drag pans, a slider sets global opacity, and *⟲ Reset view* +restores the default camera. ## Mask → RTSTRUCT (→RS) The **→RS** button converts a mask to RTSTRUCT closed planar contours: -marching squares per axial slice, loops stitched and decimated, points -mapped to patient coordinates. The new ROI is appended to the active -structure set — or a new in-memory set ("Segmentations") is created when -the study has no RTSTRUCT — so it renders like any ROI, participates in -the 3D view, and rides the existing DICOM export +marching squares per axial slice, loops stitched and decimated, points mapped +to patient coordinates. The new ROI joins the active structure set — or a new +in-memory set ("Segmentations") when the study has no RTSTRUCT — and renders +like any ROI, participates in the 3D view, and rides the existing DICOM export ([export-and-tools.md](export-and-tools.md)). ## Verification `tests/segmentation.rs` covers the invariants: the brush sphere respects anisotropic spacing, 2D strokes stay slice-confined, undo restores voxel -counts exactly, the geodesic grow respects organ boundaries under a 20× -reach increase (no-leak test), hole filling, `mask → RTSTRUCT` contour -round-trips, and the mask/contour → mesh pipeline on an analytic sphere. +counts exactly, the geodesic grow respects organ boundaries under a 20× reach +increase (no-leak test), hole filling, `mask → RTSTRUCT` contour round-trips, +and the mask/contour → mesh pipeline on an analytic sphere. diff --git a/docs/segvol.md b/docs/segvol.md index df56fe8..9790281 100644 --- a/docs/segvol.md +++ b/docs/segvol.md @@ -1,15 +1,14 @@ # Prompt-driven segmentation The auto-segmentation engine ([auto-segmentation.md](auto-segmentation.md)) -gives 117 anatomical classes with no interaction. Every one of them is normal -anatomy, which for a radiotherapy station means organs at risk and nothing -else — no GTV, no nodal disease, no post-surgical cavity, no recurrence. -Those are exactly the structures that take longest to draw and that no -fixed-class model will ever cover, because they are patient-specific by -definition. - -This engine is the complement. You point at something and it segments it: -a box, a click, or a structure name in plain text. It is a pure-Rust +gives 117 anatomical classes with no interaction — all normal anatomy, +which for a radiotherapy station means organs at risk and nothing else: no +GTV, no nodal disease, no post-surgical cavity, no recurrence. Those take +longest to draw, and no fixed-class model will ever cover them, because +they are patient-specific by definition. + +This engine is the complement: point at something — a box, a click, or a +structure name in plain text — and it segments it. It is a pure-Rust re-implementation of [SegVol](https://github.com/BAAI-DCAI/SegVol) (Du et al., NeurIPS 2024) — no Python, no ONNX Runtime, no CUDA. @@ -17,9 +16,9 @@ re-implementation of [SegVol](https://github.com/BAAI-DCAI/SegVol) **Tools ▶ 🧠 Prompt-segment dataset A…**, or the **🧠 Prompt…** button in the sidebar *Segmentations* section, opens the tool window (**🧠 Prompt -segmentation — dataset A**). It stays open across runs and reports each -result on its last line; the three segmentation engines share one window -layout, see [architecture.md](architecture.md#the-three-engine-windows). +segmentation — dataset A**; the three engines share one window layout, see +[architecture.md](architecture.md#the-three-engine-windows)). It stays open +across runs and reports each result on its last line. Move the crosshair onto the structure first; the prompt is anchored to it. @@ -29,29 +28,29 @@ Move the crosshair onto the structure first; the prompt is anchored to it. | **Point** | A single foreground click at the crosshair | Compact, well-separated structures | | **Text** | A structure name through the model's trained template | Anatomy the 117-class model does not cover | -The result arrives as an ordinary segmentation: editable with the brush and -eraser, visible in the 3D window, and convertible to RTSTRUCT — so the usual -loop is *prompt, fix by hand, export*. +The result is an ordinary segmentation — editable with brush and eraser, +visible in 3D, convertible to RTSTRUCT — so the usual loop is *prompt, fix +by hand, export*. ### Options -* **Refinement pass** — the second, sliding-window pass. Without it you get a +* **Refinement pass** — the second, sliding-window pass; without it a single coarse pass: much faster, much blockier. -* **Skip the search pass (box only)** — the first pass exists only to *locate* - the structure. With a box drawn by hand it is redundant, so skipping it - roughly halves the work and avoids losing small lesions to the downsample. - **This departs from the reference implementation**, which always runs both; - it is off by default. -* **Threshold** — probability cut applied to the network's output, 0.5 by - default. +* **Skip the search pass (box only)** — the first pass only *locates* the + structure and is redundant with a hand-drawn box; skipping it roughly + halves the work and avoids losing small lesions to the downsample. **This + departs from the reference implementation**, which always runs both; off + by default. +* **Threshold** — probability cut on the network's output, 0.5 by default. * **Compute** — *Auto* (GPU for the image encoder when available, else CPU), *GPU*, or *CPU*. -* **Model folder** — the root every engine downloads into, `models/` next - to the executable by default; this engine's files go to `models/segvol/`. +* **Model folder** — the root every engine downloads into (default: see + [auto-segmentation.md](auto-segmentation.md#using-it-in-the-viewer)); + this engine uses its `segvol/` sub-folder. -A box drawn directly in the image, with include / exclude clicks and a live -preview, is what [slice propagation](medsam2.md) offers; this window keeps -the crosshair-anchored prompt because SegVol's box is three-dimensional. +A box drawn in the image, with include / exclude clicks and a live preview, +is what [slice propagation](medsam2.md) offers; this window keeps the +crosshair-anchored prompt because SegVol's box is three-dimensional. ## Headless @@ -62,31 +61,31 @@ cargo run --release --example segvol_cli -- \ [--text liver] [--no-zoom-in] [--fast-box] [--threshold F] [--out mask.raw] ``` -`--models` is the engine's folder, `models/segvol/` next to the executable +`--models` is the engine's folder, `segvol/` in the viewer's model folder by default. -Coordinates are in the *prepared* grid — canonically oriented `[S, A, R]` and -cropped to the foreground — which is what the network sees. `--out` writes one +Coordinates are in the *prepared* grid — canonically oriented `[S, A, R]` +and cropped to the foreground, what the network sees. `--out` writes one byte per voxel on the original volume's grid. -`examples/segvol_probe` fetches the checkpoint and checks it against the -layout the port expects, printing the tensor inventory. +`examples/segvol_probe` fetches the checkpoint, checks it against the +layout the port expects and prints the tensor inventory. ## How it works -The network only ever accepts a **32 × 256 × 256** volume. That is not a -configuration choice: the image encoder's position embedding is a learned -2048-token parameter with no interpolation logic, and the mask decoder -contains a `LayerNorm` whose shape is the literal `(192, 16, 32, 32)` -activation. So a study is segmented by running the same fixed graph twice — -once over the whole volume squashed into that shape to find the structure, -then again as a sliding window over a crop around what the first pass found. +The network only accepts a **32 × 256 × 256** volume — not a configuration +choice: the image encoder's position embedding is a learned 2048-token +parameter with no interpolation logic, and the mask decoder contains a +`LayerNorm` whose shape is the literal `(192, 16, 32, 32)` activation. So +the same fixed graph runs twice — over the whole volume squashed into that +shape to find the structure, then as a sliding window over a crop around +what the first pass found. -Preprocessing is unlike the nnU-Net engine's: **no HU window and no resample -to a target spacing**. Intensities are normalized from the volume's own -statistics — threshold at the mean, take the 0.05/99.95 percentiles and the -mean and standard deviation of the voxels above it, clip and z-score — then -min-max to [0,1] and crop the resulting zero rim. +Preprocessing is unlike the nnU-Net engine's: **no HU window and no +resample to a target spacing**. Intensities are normalized from the +volume's own statistics — threshold at the mean, take the 0.05/99.95 +percentiles and the mean and standard deviation of the voxels above it, +clip and z-score — then min-max to [0,1] and crop the resulting zero rim. | | | |---|---| @@ -101,21 +100,21 @@ and runs on any GPU `wgpu` can drive when the `gpu` feature is on (default). ### Why the text tower is native -The smaller build would have been a table of precomputed embeddings over a -curated structure vocabulary. It was rejected for two reasons: generating the -table requires running PyTorch offline, so the shipped artifact would be -downstream of a Python step this project cannot reproduce — and it removes -free-text prompts, SegVol's headline capability. The tower's weights are -inside the checkpoint being downloaded anyway, so implementing it natively -costs no extra bytes over the wire beyond the tokenizer's two small data -files; encoded prompts are cached by string, so repeated prompts skip the -tower entirely, which recovers the table's practical benefit. +The smaller build — a table of precomputed embeddings over a curated +structure vocabulary — was rejected for two reasons: generating it means +running PyTorch offline, so the shipped artifact would be downstream of a +Python step this project cannot reproduce, and it removes free-text +prompts, SegVol's headline capability. The tower's weights are inside the +checkpoint being downloaded anyway, so the native tower costs no extra +bytes over the wire beyond the tokenizer's two small data files; encoded +prompts are cached by string, so repeated prompts skip the tower entirely, +which recovers the table's practical benefit. ## Weights, and their licence The checkpoint (~724 MB) is downloaded from -[huggingface.co/BAAI/SegVol](https://huggingface.co/BAAI/SegVol) on first use, -along with the CLIP tokenizer's two small data files, into `models/segvol/` +[huggingface.co/BAAI/SegVol](https://huggingface.co/BAAI/SegVol) on first +use, with the CLIP tokenizer's two small data files, into `models/segvol/` under the model folder, and converted once into a `safetensors` cache beside it. The tool window says whether the weights are cached or how much a run will download. @@ -132,32 +131,32 @@ pre-download. ## Accuracy, and what that means here -The paper reports ≈0.86 mean Dice on AMOS22 organs and ≈0.70 on lesions. -0.70 is assistance, not delineation. Treat every prompted mask as a starting -point to be corrected, not a contour — and note that this whole program is a -viewer for research and QA convenience, **not a medical device**. +The paper reports ≈0.86 mean Dice on AMOS22 organs and ≈0.70 on lesions — +assistance, not delineation. Treat every prompted mask as a starting point +to be corrected, not a contour — and this whole program is a viewer for +research and QA convenience, **not a medical device**. ## Validation status -The port is verified structurally rather than numerically: the published +The port is verified structurally, not numerically: the published checkpoint's 475-tensor inventory is recorded in `tests/data/segvol-tensors.csv` and asserted module by module, the network -assembles and runs against those exact key names and shapes in CI, and every -kernel is checked against hand-computed values. What has **not** been done is -a layer-by-layer numerical comparison against the reference implementation, -which is what the auto-segmentation engine's mean Dice 0.9995 rests on. Until -that is run, treat the output as untested against the original. - -When that validation is run, it should look like this. The normative -reference is HF `model_segvol_single.py` — the GitHub and Hugging Face -pipelines differ in places (reorientation, padding), and the HF file is -self-contained and is what the published demo runs. Dump its activations at -four cut points — after the patch embed, after ViT block 6, after the final -encoder norm, and at the decoder's low-resolution logits — and compare each -to 1e-4 absolute. Then a full two-pass run on the bundled example study, -box-prompted, compared by Dice against the reference: **> 0.99 is the pass -mark; below that is a bug, not noise.** CPU and GPU masks on identical input -should agree bit-for-bit, since the final step is a threshold rather than an -argmax over near-equal logits. Running the Python reference at development -time does not violate the one-language rule, which is about what ships in the -binary — a comparison harness is a test fixture. +assembles and runs against those exact key names and shapes in CI, and +every kernel is checked against hand-computed values. Not yet done: a +layer-by-layer numerical comparison against the reference implementation +— what the auto-segmentation engine's mean Dice 0.9995 rests on. Until +then, treat the output as untested against the original. + +That validation should use HF `model_segvol_single.py` as the normative +reference — self-contained, what the published demo runs; the GitHub and +Hugging Face pipelines differ in places (reorientation, padding). Dump its +activations at four cut points — after the patch embed, after ViT block 6, +after the final encoder norm, and at the decoder's low-resolution logits — +and compare each to 1e-4 absolute; then a full two-pass, box-prompted run +on the bundled example study, compared by Dice against the reference: +**> 0.99 is the pass mark; below that is a bug, not noise.** CPU and GPU +masks on identical input should agree bit-for-bit: the final step is a +threshold, not an argmax over near-equal logits. Running the Python +reference at development time does not violate the one-language rule, +which is about what ships in the binary — a comparison harness is a test +fixture. diff --git a/docs/structure-algebra.md b/docs/structure-algebra.md new file mode 100644 index 0000000..e80b5d1 --- /dev/null +++ b/docs/structure-algebra.md @@ -0,0 +1,135 @@ +# Combining structures and segmentations + +Union, intersection, subtraction and symmetric difference over any mix of RT +structures and segmentations, a margin on any operand and on the result, and a +little tidying at the end — the everyday arithmetic of a planning department, +done in the viewer. + +## What it is for + +`Lungs = Lung_L ∪ Lung_R`. `PTV = CTV + 5 mm`. `PTV_eval = PTV ∩ (BODY − 5 +mm)`. `Ring = (PTV + 10 mm) − (PTV + 2 mm)`. `Parotid_spared = Parotid_L − +(PTV + 3 mm)`. All are tedious, easy to get backwards, and impossible to check +afterwards if you cannot see what was combined with what. + +So the tool keeps the operand list **ordered and visible**, with ↑ ↓ arrows — +three of the four operations are not commutative in the way people assume — +and prints the recipe as one line above the buttons — + +``` +PTV_eval = PTV ∩ (BODY -5.0 mm) +``` + +— the cheapest guard against the mistake it makes easy: a subtraction with its +operands the wrong way round. + +## Contours and masks are the same thing here + +An RT structure stores contours in patient coordinates, a segmentation voxels +on a lattice, and mixing them is the normal case: every operand is rasterized +onto the displayed series' lattice on the way in — a contour through +`segmentation::rasterize_roi`, a segment on another lattice through +`dicomseg::resample_mask`, one already on it not at all — and the result goes +out as whichever kind you ask for. + +The answer is thus a **voxel** answer on the displayed series' grid; *an RT +structure* output is converted back with the usual marching-squares walk — the +outline of the voxels, not a polygon operation on the input polygons. On a 1 +mm CT the difference is invisible; on a 5 mm one it is a staircase, which the +smoothing option is for. + +## Using it + +*Tools ▶ ◧ Combine structures in dataset A…*, the **◧ Combine** button in the +sidebar, or — usually quickest — tick the structures in the data tree, +right-click and choose **◧ Combine …**: the window opens with them listed in +the order ticked. + +* **Operation** — union, intersection, subtraction or symmetric difference, + folded left to right over the list. Three operands under subtraction mean `A + − B − C`. +* **The operand list** — one row each: which structure, and the margin applied + *before* combining. This is what makes the tool expressive: a crop is an + intersection whose second operand was shrunk first, a ring a subtraction + between two expansions of the same structure. +* **R/L/A/P/S/I** on any row opens six fields instead of one, for a margin + that differs by direction. +* **Result** — a margin on the combined mask, then the tidying: fill interior + cavities, smooth, and either keep only the largest piece or drop everything + under a given volume. +* **Name … as** — a segmentation or an RT structure; for a structure, its + interpreted type (`PTV`, `ORGAN`, `EXTERNAL`, …), which a planning system + branches on. + +The result lands like any other segmentation — editable with the brush, +visible in the 3-D view, exportable — or as a ROI in the active structure set. + +## Margins are in patient directions + +"8 mm superiorly" must mean the same on an axial CT, a coronal MR and an +obliquely acquired series, so a margin is six numbers in **patient** +directions — right, left, anterior, posterior, superior, inferior — and the +direction cosines decide which array axis each is and which way along it. A +feet-first series grows toward the head just the same; a test says so. + +Positive grows, negative shrinks, and the two may be mixed in one margin: the +expansion runs first, then the contraction. + +### The shape of a margin + +The structuring element is the ellipsoid whose semi-axis in each of the six +directions is the corresponding number — what a planning system means by "5 mm +laterally, 8 mm superiorly". Three cases, three costs: + +| Margin | Structuring element | Cost | +|---|---|---| +| one number | a ball | one distance transform | +| three (symmetric per axis) | an ellipsoid | one distance transform | +| six (one-sided) | an ellipsoid per octant | eight | + +The exact anisotropic Euclidean distance transform in +[`morphology.rs`](architecture.md#module-map) does the work: a margin is the +same in millimetres along every axis whatever the slice thickness, at a cost +independent of its size. The asymmetric case is the union of the shape's eight +octants — dilation distributes over a union of structuring elements — each +reached by three *one-sided* passes of the transform, restricted to sources on +one side. + +Erosion is the complement of dilating the complement, inheriting the +convention that voxels outside the volume are not background: a structure +truncated by the field of view is not eroded at the cut, because nothing is +inferred about what was never imaged. + +## What it will not do + +* **Cross datasets.** Operands come from the displayed dataset; carrying one + from the other is what [propagation](propagation.md) is for, and done + silently here it would make the result depend on registration quality + without saying so. +* **Preserve contour geometry exactly.** As above: the result is the outline + of a voxel answer. +* **Guess.** An empty operand list, a one-operand subtraction, or an operand + that rasterizes to nothing on the displayed series is refused with a message + rather than quietly dropped — a recipe missing a term still produces a + plausible-looking structure. + +## Verification + +`src/structops.rs`'s own tests cover the algebra: the four operations on known +bitmaps, left-to-right folding over three operands, a label-map operand read +as a mask rather than as numbers, margins in patient directions on two +lattices stored opposite ways up, mixed grow-and-shrink margins, the cleanup +steps, and the two recipes worth naming — a crop and a ring. + +`tests/structops.rs` covers the seam with the application: a contour +rasterized from patient-space polygons intersected and unioned with a painted +mask, a result converted back to contours and rasterized again (agreeing to +better than half a percent of volume on a deliberately non-convex L-shaped +union), a superior margin on a feet-first lattice, a wrong-way subtraction +coming out empty rather than wrong, and `keep largest` rescuing a cut that +left a sliver. + +The margin machinery is checked in `src/morphology.rs` against a brute-force +dilation written from the definition, over isotropic, symmetric-anisotropic, +one-sided and axis-disabled margins, plus the identity that a symmetric margin +agrees with the plain ball. diff --git a/docs/viewer.md b/docs/viewer.md index 8e6d6ba..f85a90b 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -1,98 +1,89 @@ # Image viewing, datasets and interaction -This page covers everything about getting image data into the viewer and -navigating it: the loading pipeline, the three-view MPR layout, the dataset -tree, comparison mode, planar images, and the complete interaction reference. - ![single dataset](screenshot.png) -*A lung 4DCT phase with its RT Structure Set. The crosshair sits in the -tumor; the axial view draws the native RTSTRUCT contours, -sagittal/coronal show reconstructed cross-sections of the same ROIs.* +*A lung 4DCT phase with its RT Structure Set. The crosshair sits in the tumor; +the axial view draws the native RTSTRUCT contours, sagittal/coronal show +reconstructed cross-sections of the same ROIs.* ## Loading and volume reconstruction Opening a folder (*File ▶ Add DICOM folder…*, or directory arguments on the command line) starts a background scan: -1. **Classification.** Every file in the directory tree is read header-only - (up to but excluding pixel data) in parallel. Files are classified by SOP - class / modality: image series (CT/MR/PT/…), RTSTRUCT, RTDOSE, RTPLAN, - planar images (DX/CR/RTIMAGE), REG spatial registrations, RT treatment - records. Unreadable or foreign files become warnings, never errors. -2. **Series grouping.** Image files are grouped by SeriesInstanceUID and - presented in the dataset tree; the largest series is reconstructed first - (switchable at any time by clicking another series). -3. **Volume reconstruction.** Slices of the chosen series are decoded in - parallel (`rayon`), including compressed transfer syntaxes (JPEG - lossless, RLE, …) via `dicom-rs`'s pure-Rust decoders. Slices are sorted - by their projection onto the true slice normal (the cross product of the - ImageOrientationPatient row/column vectors), checked for uniform spacing - and consistent dimensions, and rescaled to HU with the per-file rescale - slope/intercept. The result is a single `i16` volume with full +1. **Classification.** Every file in the tree is read header-only (no pixel + data) in parallel and classified by SOP class / modality: image series + (CT/MR/PT/…), RTSTRUCT, RTDOSE, RTPLAN, planar images (DX/CR/RTIMAGE), REG + spatial registrations, RT treatment records. Unreadable or foreign files + become warnings, never errors. +2. **Series grouping.** Image files are grouped by SeriesInstanceUID into the + dataset tree; the largest series is reconstructed first (click another to + switch). +3. **Volume reconstruction.** Slices are decoded in parallel (`rayon`) — + compressed transfer syntaxes (JPEG lossless, RLE, …) via `dicom-rs`'s + pure-Rust decoders — sorted by projection onto the true slice normal (cross + product of the ImageOrientationPatient row/column vectors), checked for + uniform spacing and consistent dimensions, and rescaled to HU with the + per-file rescale slope/intercept. The result is one `i16` volume with full patient-space geometry (origin at the center of voxel (0,0,0), unit direction vectors for the three axes, spacing in mm). -Non-uniform slice spacing is detected and reported as a warning (the median -spacing is used for display). Duplicate slice positions are collapsed. -Enhanced multi-frame image series are not yet supported (classic -single-frame series only). - -RT objects found in the folder are parsed alongside and attached to the -study — see [rt-objects.md](rt-objects.md). +Non-uniform slice spacing is reported as a warning (the median spacing is used +for display) and duplicate slice positions are collapsed. Enhanced multi-frame +image series are not yet supported (classic single-frame only). RT objects +found in the folder are parsed alongside and attached to the study — see +[rt-objects.md](rt-objects.md). ## The three-view MPR layout -The main area shows **axial, sagittal and coronal** planes side by side -with linked crosshairs: clicking a point in any view moves all three (and, -in comparison mode, the other dataset's views) to that patient-space -position. The three planes are extracted in acquisition index space, which -maps directly onto axial/sagittal/coronal for standard axial acquisitions; -oblique acquisitions display consistently but the plane names are nominal — -the anatomical edge labels (L/R/A/P/S/I) always reflect the true patient -directions derived from the direction cosines. - -The views tile the central area edge to edge — no gaps, no frames — and -each one carries its own **slice scrubber** along its bottom edge, drawn -over the image rather than in a strip beneath it. The plane and dataset -name in the top-left corner is white in every view; the anatomical edge -labels keep their own colour. - -Each viewport carries two corner buttons (both name themselves on hover): -**⟲** resets that view's zoom and pan and puts the crosshair back at the -volume center — which returns that dataset's three views to their central -slices — and **⛶ / ❐** maximizes the view to fill the window and restores -the layout again. The toolbar holds a global **⟲** (the same reset for +The main area shows **axial, sagittal and coronal** planes side by side with +linked crosshairs: clicking a point in any view moves all three to that +patient-space position. Planes are extracted in acquisition index space; +oblique acquisitions display consistently but their plane names are nominal, +and the anatomical edge labels (L/R/A/P/S/I) always reflect the true patient +directions from the direction cosines. + +The views tile the central area edge to edge, each with its own **slice +scrubber** drawn over its bottom edge; the plane and dataset name in the +top-left corner is white in every view, the edge labels keep their colour. +Two corner buttons (named on hover): **⟲** resets the view's zoom and pan and +re-centers the crosshair in the volume, **⛶ / ❐** maximizes the view and +restores the layout. The toolbar holds a global **⟲** (the same reset for every view of both datasets), the **⌖** crosshair toggle (while hidden, -left-click navigation is disabled entirely and slices change only by -scrolling), the **🔗** crosshair-sync toggle next to it (shown while the -crosshair is on, active with two datasets loaded), the **3D A / 3D B** -buttons and the segmentation tools. +left-click navigation is off and slices change only by scrolling), the **🔗** +crosshair-sync toggle beside it (shown while the crosshair is on, active with +two datasets loaded), the **3D A / 3D B** buttons and the segmentation +tools. **Window/level.** Right-drag on any view adjusts interactively -(x = width, y = center); the toolbar offers the numeric fields and the -common CT presets: brain, subdural, stroke, head/neck soft tissue, temporal -bone, lungs, mediastinum, abdomen, liver, spine, bone, CT angio, full -range. The list shows each preset's center and width; once one is chosen -the closed list carries its name alone (*Lungs*, *Liver*, …) — the two -numeric fields next to it already say what the numbers are. Any other -window, from a drag or the full range, leaves the list nameless again. -Window/level is shared between datasets A and B so both CTs are windowed -identically. - -**Status bar.** Patient coordinates, voxel indices, HU and dose (Gy and % -of the reference dose) at the crosshair; in comparison mode both datasets -report the full set side by side, each at its own crosshair. The mouse -bindings of the tool in force are behind the **?** at the right end — hover -it to read them. - -**The left panel.** *View ▶ Left panel*, the **F9** key and the arrow on -the window's left edge hide and show it; dragging its inner edge past the -minimum width does the same, and the arrow stays on screen to bring it -back. Which sections it holds is up to the *Modules* menu: **Image -registration** and **Image simulation** are off until switched on, and the -choice is remembered between runs. The data tree of each loaded dataset is -always there. +(x = width, y = center); the toolbar offers numeric fields and the common CT +presets: brain, subdural, stroke, head/neck soft tissue, temporal bone, lungs, +mediastinum, abdomen, liver, spine, bone, CT angio, full range. The list +shows each preset's center and width; the closed list carries only the chosen +name, and any other window — a drag or the full range — leaves it nameless. +Window/level is shared between datasets A and B. + +**Tool windows on their own screen.** Every secondary window — the archive, +the model manager, the DRR, the 3D scenes, the segmentation and motion tools, +the export and anonymizer dialogs — has a **Detach** button in its top-right +corner that makes it an operating-system window of its own, to drag onto a +second or third monitor, resize or maximize there, and keep open beside the +images while the main window keeps all six viewports; **Dock** puts it back. +Several can be out at once; each reopens where it was left, on the same +monitor, and which are detached is remembered between runs +(`detached_windows` in the settings file). Closing a detached window only +closes that tool — it opens in its own window again next time. + +**Status bar.** Patient coordinates, voxel indices, HU and dose (Gy and % of +the reference dose) at the crosshair; in comparison mode both datasets report +the full set side by side, each at its own crosshair. Hover the **?** at the +right end to read the active tool's mouse bindings. + +**The left panel.** *View ▶ Left panel*, **F9** and the arrow on the window's +left edge hide and show it; dragging its inner edge past the minimum does the +same, and the arrow brings it back. The *Modules* menu chooses its sections: +**Image registration** and **Image simulation** are off until switched on, +remembered between runs. Each dataset's data tree is always there. ## Interaction reference @@ -104,18 +95,17 @@ always there. | Middle drag | Pan | | Right drag | Window/level (x = width, y = center) | -With a segmentation tool active the left button paints instead of -navigating — see [segmentation.md](segmentation.md) for those bindings. -The bindings of the active tool are always shown in the status bar and in -full under *Help*. +With a segmentation tool active the left button paints instead of navigating — +see [segmentation.md](segmentation.md); the full bindings are under *Help*. ## Datasets and the patient ▶ study ▶ series tree -The two viewer slots are **dataset A** and **dataset B** — each is a -working set that can hold any number of patients, studies and series -accumulated from any number of folders. *File ▶ Add DICOM folder to A/B…* -merges a scanned folder into the slot without unloading what is already -there; duplicates (by UID) are skipped and reported. +The two viewer slots, **dataset A** and **dataset B**, each hold any number of +patients, studies and series from any number of folders. *File ▶ Add DICOM +folder to A/B…* merges a scanned folder into the slot without unloading what +is there; duplicates (by UID) are skipped and reported. *Tools ▶ 🏥 PACS — +patient archive…* fills a slot the same way from the application's own store +of studies ([pacs.md](pacs.md)) — an archived study folder is ordinary DICOM. The left panel shows each dataset as a full DICOM hierarchy: @@ -137,171 +127,141 @@ Dataset A ``` The modality level (CT / MR / US / PT …) is one DICOM implies but does not -store as a node; it is grouped from the series' Modality, in first-seen -order. Everything that carries a StudyInstanceUID — image series, RT -structure sets, segmentation series, dose grids and plans — sits inside the -study it belongs to. An RT object whose StudyInstanceUID is blank or names -a study that is not loaded is filed under the study of the image series it -references, and failing that under the first study, because an object that -cannot be reached is worse than one shown a level from where its header -claims it lives. - -What is left below the tree is what has no study to sit under: planar -images carry no study link at all, spatial registrations and treatment -records belong to a frame of reference rather than a study, and **Dose -display** — colorwash, isodose ladder, opacity, threshold — is one setting -shared by both datasets, so it is shown once. +store as a node; it is grouped from the series' Modality in first-seen order. +Everything with a StudyInstanceUID — image series, RT structure sets, +segmentation series, dose grids and plans — sits inside its study; an RT +object whose StudyInstanceUID is blank or names an unloaded study goes under +the study of the image series it references, failing that under the first +study. Planar images, spatial registrations and treatment records have no +study and sit below the tree, as does **Dose display** — colorwash, isodose +ladder, opacity, threshold — one setting shared by both datasets, shown once. The displayed series is marked; clicking another loads it. Long names, -descriptions and IDs wrap over as many lines as they need, so the panel can -be dragged narrow without cutting them off. The standard reference chain is -parsed and shown as links: each structure set and segmentation series -displays the image series it is drawn on, each dose the plan it was -computed for (ReferencedRTPlanSequence), and each plan the structure set it -was created on (ReferencedStructureSetSequence). - -**Right-clicking** any level of the tree — patient, study or series — -opens a context menu to **rename**, **copy**, **move** or **remove** it. Copy/move -transfer the selection into the other dataset (A ▶ B or B ▶ A), merging it -with whatever is already loaded there and switching comparison mode on; -move and remove then delete the selection from its source. A single series -carries exactly its DICOM reference chain: the structure sets drawn on it, -the plans made on those structure sets, and the doses computed for those -plans — nothing else. Study and patient selections additionally take the -RT objects filed under the same studies. Right-clicking a dataset header -offers *Clear dataset*. +descriptions and IDs wrap, so the panel can be dragged narrow. The reference +chain is shown as links: each structure set and segmentation series shows the +image series it is drawn on, each dose the plan it was computed for +(ReferencedRTPlanSequence), each plan the structure set it was created on +(ReferencedStructureSetSequence). + +**Right-clicking** a patient, study or series opens a context menu to +**rename**, **copy**, **move** or **remove** it. Copy/move transfer the +selection into the other dataset (A ▶ B or B ▶ A), merging with what is there +and switching comparison mode on; move and remove then delete it from its +source. A series carries exactly its DICOM reference chain — the structure +sets drawn on it, the plans made on those, the doses computed for those +plans — and study and patient selections also take the RT objects of their +studies. Right-clicking a dataset header offers *Clear dataset*. ## Structures and segmentations in the tree -Below the image series, each dataset lists its **RT structures** and its -**Segmentations** as series nodes — one per RT structure set, one per -DICOM Segmentation series — each showing the image series it is drawn on -(`▶ CT chest`, or `▶ (unlinked)`). Clicking a node makes it the active -one; the items of the active node are listed underneath. - -*➕ New series* creates an empty structure set / segmentation series bound -to the displayed image series. **Right-clicking a series node** offers: - -* *🔗 Connect to image series ▶* — re-point the series at any image series - of the dataset (● marks the current one). Contours are in patient - coordinates and simply follow; a segmentation series is resampled onto - the new series' lattice the next time that series is displayed. +Below the image series, each dataset lists its **RT structures** and +**Segmentations** as series nodes — one per RT structure set or DICOM +Segmentation series — each showing the image series it is drawn on +(`▶ CT chest`, or `▶ (unlinked)`). Clicking a node makes it active and lists +its items. *➕ New series* creates an empty structure set / segmentation +series bound to the displayed series. **Right-clicking a series node** +offers: + +* *🔗 Connect to image series ▶* — re-point the series at any image series of + the dataset (● marks the current one); contours are in patient coordinates + and simply follow, a segmentation series is resampled onto the new lattice + when next displayed. * *Copy / Move series to dataset A/B*. -* *💾 Export as DICOM SEG…* (segmentation series only) — writes this one - series as a single SEG file. +* *💾 Export as DICOM SEG…* (segmentation series only) — writes this one series + as a single SEG file. * *🗑 Remove this RT structure set / segmentation series*. * *✎ Rename series…*. -Each item's **check box is both its visibility and its selection**, so -*All* / *None* tick everything or nothing and the actions operate on -whatever is ticked. **Shift-click** a check box to tick — or untick — the -whole range from the last one you clicked: the span is filled with the -clicked row's new value, and rows outside it are never touched, because the -box is a visibility toggle as much as a selection and silently hiding -structures you did not point at would be worse than the convenience. - -One row carries the lot: for structures **All · None · Copy to · Move to · -🗑 · *n* selected**, and for segmentations **New · All · None · Copy to · -Move to · 🗑 · 💾 · *n* selected**. *Copy to* and *Move to* open the same -destination submenu described below; **💾** writes just the ticked segments -as a DICOM SEG file of their own. The buttons grey out when nothing is -ticked. - -The per-row buttons a segment used to carry — undo, →RS, delete — are gone: -Ctrl+Z undoes the last stroke, *Copy to ▶ an RT structure set* is what →RS -did, and **🗑** deletes whatever is ticked. +Each item's **check box is both its visibility and its selection**, so *All* +/ *None* tick everything or nothing and the actions act on whatever is +ticked. **Shift-click** a check box to tick — or untick — the range from the +last one you clicked: the span takes the clicked row's new value. + +One row carries the lot: for structures **All · None · Copy to · Move to · 🗑 · +*n* selected**, for segmentations **New · All · None · Copy to · Move to · 🗑 · +💾 · *n* selected**. *Copy to* and *Move to* open the destination submenu +described below; **💾** exports the ticked segments as their own SEG file. The +buttons grey out when nothing is ticked. The per-row undo, →RS and delete +buttons are gone: Ctrl+Z undoes the last stroke, *Copy to ▶ an RT structure +set* is what →RS did, and **🗑** deletes the ticked rows. **Right-clicking a structure or segment** offers the same set for one row or the ticked group: * *Copy … to ▶* / *Move … to ▶* — a submenu of every structure set and - segmentation series in **both** datasets, plus *➕ a new RT structure - set* / *➕ a new segmentation series* as destinations. Right-clicking a - ticked row acts on all ticked rows at once; right-clicking an unticked - row acts on that row alone. + segmentation series in **both** datasets, plus *➕ a new RT structure set* / + *➕ a new segmentation series*. A ticked row acts on all ticked rows at once; + an unticked row acts alone. * *🗑 Remove …* — the same single-or-selected rule. -* *💾 Export … as DICOM SEG…* (segments only) — writes the chosen segments - as a SEG series in its own right: same lattice, same referenced image - series, a fresh SOP Instance UID, and only those segments. Exporting three - organs out of twelve therefore needs no special case in the writer, and - the file reloads as an ordinary segmentation series. +* *💾 Export … as DICOM SEG…* (segments only) — writes the chosen segments as a + SEG series of their own: same lattice, same referenced image series, a fresh + SOP Instance UID, only those segments; the file reloads as an ordinary + segmentation series. * *✎ Rename …* — always the row you clicked, never the whole selection. -Crossing between the two kinds is a conversion, done on transfer: a -structure moved into a segmentation series is rasterized onto that series' -lattice (even–odd fill, so a doughnut stays a doughnut), a segment moved -into a structure set becomes closed planar contours (marching squares), -and a segment moved between two segmentation series on different lattices -is resampled. Anything that cannot cross — a contour outside the -destination volume, a mask that does not overlap it — is reported in the -dataset's *Warnings* section instead of arriving empty. +Crossing between the two kinds converts on transfer: a structure moved into a +segmentation series is rasterized onto its lattice (even–odd fill), a segment +moved into a structure set becomes closed planar contours (marching squares), +and a segment moved between different lattices is resampled. Anything that +cannot cross — a contour outside the destination volume, a mask that does not +overlap it — lands in the dataset's *Warnings* section. ## Renaming -Everything the tree names can be renamed from its own right-click menu: -patients, studies, image series, RT structure sets, segmentation series, -individual structures and segments, dose grids, plans, planar images, -spatial registrations and treatment records. The dialog is a single text -field — Enter applies, Esc cancels, an empty name is not accepted — and it -says which DICOM attribute the text lands in. +Everything the tree names — patients, studies, image series, RT structure +sets, segmentation series, structures and segments, dose grids, plans, planar +images, spatial registrations and treatment records — can be renamed from its +right-click menu. The dialog is a single text field — Enter applies, Esc +cancels, empty names are rejected — and names the DICOM attribute it writes. A patient and a study are *groupings* rather than objects, so renaming one writes `PatientName` / `StudyDescription` into **every** series filed under -it; the tree would otherwise split into an old and a new node. Everything -else writes the one attribute it shows: `SeriesDescription`, -`StructureSetLabel`, `ROIName`, `SegmentLabel`, `RTPlanLabel`, and the -labels of the remaining objects. - -Renames are in-memory. They change what the tree, the overlays and the 3D -view call things, and they are what a DICOM export writes out; the files a -study was loaded from are never modified. +it; everything else writes the one attribute it shows: `SeriesDescription`, +`StructureSetLabel`, `ROIName`, `SegmentLabel`, `RTPlanLabel`, and the labels +of the remaining objects. Renames are in-memory: they change what the tree, +the overlays and the 3D view call things and what a DICOM export writes; the +files a study was loaded from are never modified. ## Comparison mode ![comparison mode](screenshot_comparison.png) -*Two opposite breathing phases of the same 4DCT as datasets A and B, each -with its phase-specific structure set; the synced crosshair pins all six -views to the same patient-space point inside the tumor.* - -Load a second dataset (menu, tree copy/move, or two directories on the -command line) and the window splits into two rows of three views — dataset -A on top, dataset B below. Each dataset keeps its own structures, dose and -plan panels in the sidebar; window/level and dose display settings are -shared. The crosshair is synced between the datasets through **patient -coordinates** (the toolbar's **🔗**, or *View ▶ Sync crosshairs between -datasets* — both appear only while the crosshair itself is on); -when a registration is active, the link maps through the recovered -transform instead — see [registration.md](registration.md). - -A concrete example with the bundled data: load `example_data/` and both -4DCT phases appear as two series of one study. Right-click -*CT 4DCT_phase_050* ▶ *Copy series to dataset B* — the phase moves into -the lower row together with its own phase-specific RTSTRUCT (the reference -chain picks the correct one automatically), and comparison mode switches -on. Click the tumor in any view: all six panels jump to that point, and the -respiratory differences between the phases are read directly by comparing -the rows. +*Two opposite breathing phases of the same 4DCT as datasets A and B, each with +its phase-specific structure set; the synced crosshair pins all six views to +the same patient-space point inside the tumor.* + +Load a second dataset (menu, tree copy/move, or two directories on the command +line) and the window splits into two rows of three views — dataset A on top, +dataset B below. Each dataset keeps its own structures, dose and plan panels +in the sidebar; window/level and dose display are shared. The crosshair is +synced through **patient coordinates** (the toolbar's **🔗**, or *View ▶ Sync +crosshairs between datasets* — both appear only while the crosshair itself is +on); with a registration active, the link maps through the recovered transform +instead — see [registration.md](registration.md). + +With the bundled data: load `example_data/`, and both 4DCT phases appear as +two series of one study. Right-click *CT 4DCT_phase_050* ▶ *Copy series to +dataset B* — the phase moves into the lower row with its own phase-specific +RTSTRUCT and comparison mode switches on. Click the tumor in any view: all six +panels jump to that point, and the rows show the respiratory differences. ## Planar images (DX / CR / RTIMAGE) -Digital radiographs and RT images (portal/setup images) found in the study -folder — plus any DRR added from the DRR window with *➕ Add to dataset A/B* -(see [drr.md](drr.md)) — are listed in the sidebar and open in floating -viewer windows -with their own window/level (opens at the DICOM default; auto, manual, or -interactive right-drag exactly like the CT views), correct physical aspect -ratio (imager / image-plane pixel spacing), MONOCHROME1 inversion, and the -relevant metadata — body part, view and kVp for DX; machine, gantry angle, -SAD and SID for RTIMAGE. +Digital radiographs and RT images (portal/setup images) in the study folder — +plus any DRR added from the DRR window with *➕ Add to dataset A/B* (see +[drr.md](drr.md)) — are listed in the sidebar and open in floating viewer +windows with their own window/level (DICOM default at open; auto, manual, or +right-drag like the CT views), correct physical aspect ratio (imager / +image-plane pixel spacing), MONOCHROME1 inversion, and metadata — body part, +view and kVp for DX; machine, gantry angle, SAD and SID for RTIMAGE. ## Appearance -*View ▶ Appearance* switches between **🌙 Dark**, **☀ Light** and -**💻 System** (follows the OS setting and updates live). The choice is -remembered in `viewer_settings.txt` next to the executable — a tiny -`key = value` text file, safe to edit or delete. The image viewports stay -black in both themes, as in clinical viewers, so grayscale windowing, the -dose colorwash and the overlay annotations keep a single calibrated -appearance; unit tests assert the accent colors clear WCAG AA contrast -against both backgrounds. +*View ▶ Appearance* switches between **🌙 Dark**, **☀ Light** and **💻 System** +(follows the OS setting and updates live). The choice is remembered in +`viewer_settings.txt` (`%LOCALAPPDATA%\RustDICOMStation` on Windows, +`~/.config/RustDICOMStation` on Linux), a tiny `key = value` text file, safe +to edit or delete. The image viewports stay black in both themes so +windowing, the dose colorwash and the overlays keep one calibrated +appearance; unit tests assert the accent colors clear WCAG AA contrast on +both backgrounds. diff --git a/examples/autoseg_cli.rs b/examples/autoseg_cli.rs index f613b39..da07391 100644 --- a/examples/autoseg_cli.rs +++ b/examples/autoseg_cli.rs @@ -6,8 +6,9 @@ //! [--parts organs,vertebrae,cardiac,muscles,ribs] //! ``` //! -//! `--models` is the engine's folder, `models/totalsegmentator/` next to the -//! executable by default. +//! `--models` is the engine's folder: by default `totalsegmentator/` in the +//! viewer's model folder (`~/.local/share/RustDICOMStation/models` on Linux, +//! `%LOCALAPPDATA%\RustDICOMStation\models` on Windows). //! //! Writes `.bin` (u8 labels, `Volume::data` order) and //! `.json` (dims, spacing, origin, orientation, organ table) so diff --git a/examples/autoseg_probe.rs b/examples/autoseg_probe.rs index 5dd434c..066395f 100644 --- a/examples/autoseg_probe.rs +++ b/examples/autoseg_probe.rs @@ -3,8 +3,8 @@ //! //! cargo run --release --example autoseg_probe -- //! -//! `` is the engine's folder, normally `models/totalsegmentator/` -//! next to the executable. +//! `` is the engine's folder, normally `totalsegmentator/` in the +//! viewer's model folder. use rust_dicom_station::autoseg::{config::ModelConfig, cpu, net, preprocess, weights}; use rust_dicom_station::loader; diff --git a/examples/body_cli.rs b/examples/body_cli.rs index 0a0c69e..564165c 100644 --- a/examples/body_cli.rs +++ b/examples/body_cli.rs @@ -107,7 +107,15 @@ fn main() -> anyhow::Result<()> { ); // Modality-appropriate defaults, overridden by whatever was asked for. - p.foreground = Foreground::for_modality(&modality); + // `--bias-sigma` on its own is a modifier, not a mode: it has to survive + // this line rather than be overwritten by it. + p.foreground = match Foreground::for_modality(&modality) { + Foreground::MrRelative { fraction, .. } => Foreground::MrRelative { + fraction, + sigma_mm: bias_sigma, + }, + other => other, + }; if !model_forced { p.model = BodyModel::for_modality(&modality); } diff --git a/examples/medsam2_cli.rs b/examples/medsam2_cli.rs index d8bff10..8f3ddd0 100644 --- a/examples/medsam2_cli.rs +++ b/examples/medsam2_cli.rs @@ -8,7 +8,7 @@ //! [--all-slices] [--forward-only] [--threshold F] [--no-cleanup] [--out FILE] //! ``` //! -//! `--models` is the engine's folder, `models/medsam2/` next to the executable +//! `--models` is the engine's folder, `medsam2/` in the viewer's model folder //! by default. Slice, box and point coordinates are in the **prepared** stack //! — axial slices in reading order, which for an ordinary head-first-supine //! CT is the acquisition order. `--out` writes a raw `u8` mask on the diff --git a/examples/medsam2_probe.rs b/examples/medsam2_probe.rs index 6fca280..2cd6ff8 100644 --- a/examples/medsam2_probe.rs +++ b/examples/medsam2_probe.rs @@ -12,7 +12,7 @@ //! cargo run --release --example medsam2_probe -- [MODELS_DIR] [--variant NAME] [--keys] [--csv FILE] //! ``` //! -//! `MODELS_DIR` defaults to `models/medsam2/` next to the executable. +//! `MODELS_DIR` defaults to `medsam2/` in the viewer's model folder. //! `--variant` is one of `latest` (the default), `ct-lesion`, `mri-liver` or //! `2411`. `--keys` lists every tensor; `--csv` writes the inventory to a //! file. diff --git a/examples/segvol_cli.rs b/examples/segvol_cli.rs index 349a66e..fbe24c4 100644 --- a/examples/segvol_cli.rs +++ b/examples/segvol_cli.rs @@ -7,7 +7,7 @@ //! [--no-zoom-in] [--fast-box] [--threshold F] [--out FILE] //! ``` //! -//! `--models` is the engine's folder, `models/segvol/` next to the executable +//! `--models` is the engine's folder, `segvol/` in the viewer's model folder //! by default. Box and point coordinates are in the **prepared** grid — //! canonically oriented `[S, A, R]` and cropped to the foreground — which is //! what the network sees. `--out` writes a raw `u8` mask on the original diff --git a/examples/segvol_probe.rs b/examples/segvol_probe.rs index b291ae8..5fcf6da 100644 --- a/examples/segvol_probe.rs +++ b/examples/segvol_probe.rs @@ -11,7 +11,7 @@ //! cargo run --release --example segvol_probe -- [MODELS_DIR] [--keys] [--csv FILE] //! ``` //! -//! `MODELS_DIR` defaults to `models/segvol/` next to the executable. `--keys` +//! `MODELS_DIR` defaults to `segvol/` in the viewer's model folder. `--keys` //! lists every tensor; `--csv` rewrites the recorded inventory. use std::io::Write; diff --git a/installer/README.md b/installer/README.md index 70e8c57..185541a 100644 --- a/installer/README.md +++ b/installer/README.md @@ -52,12 +52,12 @@ publisher" warning on first run. the viewer's own downloader, so the first auto-segmentation run does not have to wait for a 135 MB … 1.3 GB download. Skipped by default. They go where the viewer keeps every engine's weights: the `totalsegmentator/` - sub-folder of the model folder, `models/` next to the executable. A - machine-wide install points that folder at - `%LOCALAPPDATA%\RustDicomStation\models` (recorded as `models_dir` in - `viewer_settings.txt`), because `Program Files` is not user-writable. The - SegVol and MedSAM2 weights are never pre-fetched — their licences allow - only a download by the user, which the viewer does on first use. + sub-folder of the model folder, by default + `%LOCALAPPDATA%\RustDICOMStation\models` for either scope. A model folder + chosen elsewhere is recorded as `models_dir` in the installing user's + `%LOCALAPPDATA%\RustDICOMStation\viewer_settings.txt`. The SegVol and + MedSAM2 weights are never pre-fetched — their licences allow only a + download by the user, which the viewer does on first use. * **Integration** — Start-menu and desktop shortcuts, an "Open with Rust DICOM Station" verb on folders (the viewer takes a directory), a `.dcm`/`.dicom` entry that is *added* to `OpenWithProgids` diff --git a/installer/src/install.rs b/installer/src/install.rs index 4769850..21f38e5 100644 --- a/installer/src/install.rs +++ b/installer/src/install.rs @@ -154,23 +154,34 @@ pub fn run(opts: &Options, payload: &Payload, sink: Sink, cancel: &AtomicBool) - )); // ---- settings seed ---------------------------------------------------- - // The viewer stores its settings next to the executable and, by default, - // the model root too. Under Program Files that folder is read-only for - // normal users, so point the root somewhere writable up front. - if opts.models_dir != default_models_dir(Scope::CurrentUser, &opts.dir) { - let settings = opts.dir.join(SETTINGS_FILE); - if !settings.exists() { - let text = format!( - "# rust-dicom-station user settings\n\ - # theme = dark | light | system\n\ - theme = dark\n\ - {SETTINGS_MODELS_KEY} = {}\n", - opts.models_dir.display() - ); - std::fs::write(&settings, text) - .with_context(|| format!("write {}", settings.display()))?; - manifest.files.push(SETTINGS_FILE.to_string()); - log(format!("Model folder set to {}", opts.models_dir.display())); + // The viewer keeps its settings and, by default, its model folder in its + // own per-user data folder, so the default needs no recording. A model + // folder chosen elsewhere is written into the settings of the user + // running the installer (the file is theirs, not the installation's, so + // it is not in the manifest); other users of a machine-wide install get + // the viewer's default and can move it from any tool window. + if opts.models_dir != default_models_dir(opts.scope, &opts.dir) { + if let Some(settings) = viewer_settings_path() { + if !settings.exists() { + if let Some(parent) = settings.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let text = format!( + "# rust-dicom-station user settings\n\ + # theme = dark | light | system\n\ + theme = dark\n\ + {SETTINGS_MODELS_KEY} = {}\n", + opts.models_dir.display() + ); + std::fs::write(&settings, text) + .with_context(|| format!("write {}", settings.display()))?; + log(format!( + "Model folder set to {} in {}", + opts.models_dir.display(), + settings.display() + )); + } } } std::fs::create_dir_all(&opts.models_dir).ok(); diff --git a/installer/src/plan.rs b/installer/src/plan.rs index 28761dd..5d2884d 100644 --- a/installer/src/plan.rs +++ b/installer/src/plan.rs @@ -15,16 +15,19 @@ pub const PRODUCT_ID: &str = "RustDicomStation"; pub const PROGID: &str = "RustDicomStation.DicomFile"; pub const UNINSTALLER_EXE: &str = "uninstall.exe"; pub const MANIFEST_FILE: &str = "install-manifest.txt"; -/// Written next to the executable by the viewer itself; the installer only -/// pre-seeds it when the program folder is not user-writable. +/// The viewer's settings file, kept in its data folder; the installer only +/// pre-seeds it when the chosen model folder is not the viewer's default. pub const SETTINGS_FILE: &str = "viewer_settings.txt"; +/// The viewer's per-user folder under `%LOCALAPPDATA%`, where it keeps its +/// settings and, by default, the model folder. Must match +/// `rust_dicom_station::settings::APP_NAME`. +pub const VIEWER_DATA_DIR: &str = "RustDICOMStation"; /// The settings key naming the model root. Must match /// `rust_dicom_station::settings::MODELS_DIR_KEY` (asserted by a test when /// the viewer is linked in). pub const SETTINGS_MODELS_KEY: &str = "models_dir"; -/// The viewer's model root folder name, `models/` next to its executable; -/// each engine keeps its own sub-folder in it. Must match -/// `rust_dicom_station::models::DIR_NAME`. +/// The viewer's model root folder name; each engine keeps its own sub-folder +/// in it. Must match `rust_dicom_station::models::DIR_NAME`. pub const MODELS_DIR_NAME: &str = "models"; /// Official Microsoft download for the x64 Visual C++ 2015-2022 runtime. pub const VCREDIST_URL: &str = "https://aka.ms/vs/17/release/vc_redist.x64.exe"; @@ -141,7 +144,7 @@ impl Options { } /// Change the destination folder, keeping a still-default model folder - /// pointed next to the new location. + /// at the default for the new location. pub fn set_dir(&mut self, dir: PathBuf) { if self.models_dir == default_models_dir(self.scope, &self.dir) { self.models_dir = default_models_dir(self.scope, &dir); @@ -178,21 +181,30 @@ pub fn default_install_dir(scope: Scope) -> PathBuf { } } +/// The viewer's own data folder, `%LOCALAPPDATA%\RustDICOMStation`. +pub fn viewer_data_dir() -> Option { + crate::win::local_app_data() + .ok() + .map(|d| d.join(VIEWER_DATA_DIR)) +} + +/// Where the viewer reads its settings from. +pub fn viewer_settings_path() -> Option { + viewer_data_dir().map(|d| d.join(SETTINGS_FILE)) +} + /// Where the model root goes — the folder all three engines download into /// (`models/totalsegmentator`, `models/segvol`, `models/medsam2`). /// -/// The viewer defaults to `models/` next to its executable, which is exactly -/// right for a per-user install. A machine-wide install lands in -/// `Program Files`, which normal users cannot write to, so the root moves to -/// `%LOCALAPPDATA%` and the installer records that in `viewer_settings.txt`. -pub fn default_models_dir(scope: Scope, install_dir: &Path) -> PathBuf { - match scope { - Scope::CurrentUser => install_dir.join(MODELS_DIR_NAME), - Scope::AllUsers => crate::win::local_app_data() - .unwrap_or_else(|_| install_dir.to_path_buf()) - .join(PRODUCT_ID) - .join(MODELS_DIR_NAME), - } +/// The viewer's default is `models/` in its per-user data folder, which is +/// writable whoever installed the program and wherever it went, so the same +/// default serves both scopes; only a folder chosen elsewhere has to be +/// recorded in `viewer_settings.txt`. The install folder is the fallback +/// when the shell cannot name `%LOCALAPPDATA%`. +pub fn default_models_dir(_scope: Scope, install_dir: &Path) -> PathBuf { + viewer_data_dir() + .unwrap_or_else(|| install_dir.to_path_buf()) + .join(MODELS_DIR_NAME) } #[cfg(all(test, feature = "prefetch-models"))] @@ -204,6 +216,10 @@ mod tests { rust_dicom_station::settings::MODELS_DIR_KEY ); assert_eq!(super::MODELS_DIR_NAME, rust_dicom_station::models::DIR_NAME); + assert_eq!( + super::VIEWER_DATA_DIR, + rust_dicom_station::settings::APP_NAME + ); } } diff --git a/src/app/body_win.rs b/src/app/body_win.rs index 2bcb52c..9f440c7 100644 --- a/src/app/body_win.rs +++ b/src/app/body_win.rs @@ -13,12 +13,8 @@ //! fraction of the 99th percentile is meaningless on CT. A threshold the //! user has edited by hand is left alone. -use std::path::PathBuf; -use std::sync::Arc; - use crate::bodymask::{self, BodyModel, BodyParams, BodyResult, Foreground, Method}; use crate::models::Engine as ModelsEngine; -use crate::progress::Progress; use super::*; @@ -136,9 +132,19 @@ impl ViewerApp { // dialog is borrowed mutably. let removed_cm3 = self.voxels_to_cm3(slot, result.removed_voxels); let recovered_cm3 = self.voxels_to_cm3(slot, result.recovered_voxels); + // `1250 + 980 cm³`: the size of each body when there is more than one. let pieces = match result.pieces.len() { 0 | 1 => String::new(), - n => format!(", {n} separate bodies"), + n => format!( + ", {n} separate bodies ({})", + result + .pieces + .iter() + .map(|p| format!("{:.0}", p.cm3)) + .collect::>() + .join(" + ") + + " cm³" + ), }; let device = if result.device.is_empty() { String::new() @@ -203,13 +209,13 @@ impl ViewerApp { let running = self.body_job.as_ref().filter(|_| self.body_slot == slot); let mut open = true; let (mut run, mut close, mut browse, mut cancel) = (false, false, false, false); - egui::Window::new(BODY_CONTOUR.title(d.slot)) - .id(egui::Id::new("body_window")) - .collapsible(true) - .resizable(false) - .default_width(430.0) - .open(&mut open) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "body", + BODY_CONTOUR.title(d.slot), + &mut open, + detach::WinOpts::width(430.0).resizable(false), + |ui| { ui.label( "Finds the patient's outer surface and leaves the couch, the chair and \ the immobilisation outside it — the EXTERNAL structure everything \ @@ -445,7 +451,8 @@ impl ViewerApp { ui.separator(); ui.weak(status); } - }); + }, + ); if browse { if let Some(dir) = Self::pick_folder("Model folder") { self.models_dir = dir.display().to_string(); @@ -467,6 +474,10 @@ impl ViewerApp { } } +/// What an MR threshold starts at, and how far the bias estimate reaches. +const DEFAULT_MR_FRACTION: f32 = 0.12; +const DEFAULT_BIAS_SIGMA_MM: f64 = 40.0; + /// The threshold row — a different question on CT and on MR, so a different /// row rather than one control that means two things. fn foreground_row(ui: &mut egui::Ui, fg: &mut Foreground) { @@ -485,10 +496,15 @@ fn foreground_row(ui: &mut egui::Ui, fg: &mut Foreground) { } _ => { let mut otsu = matches!(fg, Foreground::MrOtsu { .. }); + // The fraction is remembered across a visit to Otsu and back; + // losing a dialled-in threshold to a radio button is the kind of + // small betrayal that stops people trying the other option. + let id = ui.id().with("mr_fraction"); + let remembered: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(DEFAULT_MR_FRACTION); let (mut fraction, mut sigma) = match *fg { Foreground::MrRelative { fraction, sigma_mm } => (fraction, sigma_mm), - Foreground::MrOtsu { sigma_mm } => (0.12, sigma_mm), - Foreground::Hu(_) => (0.12, 40.0), + Foreground::MrOtsu { sigma_mm } => (remembered, sigma_mm), + Foreground::Hu(_) => (remembered, DEFAULT_BIAS_SIGMA_MM), }; ui.horizontal(|ui| { ui.label("Tissue above:"); @@ -521,6 +537,7 @@ fn foreground_row(ui: &mut egui::Ui, fg: &mut Foreground) { shading and leaves every edge intact.", ); }); + ui.data_mut(|d| d.insert_temp(id, fraction)); *fg = if otsu { Foreground::MrOtsu { sigma_mm: sigma } } else { diff --git a/src/app/box_seg.rs b/src/app/box_seg.rs index d1789bc..481fad0 100644 --- a/src/app/box_seg.rs +++ b/src/app/box_seg.rs @@ -23,17 +23,12 @@ //! coordinates, and [`Medsam2State::engine_prompt`] is the only place it //! becomes network coordinates. -use std::path::PathBuf; -use std::sync::Arc; - use crate::medsam2::engine::{Engine, EnginePrompt, PixelPrompt}; use crate::medsam2::infer::Config; use crate::medsam2::preprocess::{self, Prepared, Window}; use crate::medsam2::weights::{self, Variant}; use crate::models::Engine as ModelsEngine; use crate::nn::device::DevicePref; -use crate::progress::Progress; -use crate::volume::{ViewPlane, Volume}; use super::*; @@ -758,13 +753,13 @@ impl ViewerApp { let mut clear = false; let mut browse = false; - egui::Window::new(SLICE_PROP.title(slot)) - .id(egui::Id::new("medsam2_window")) - .collapsible(true) - .resizable(false) - .default_width(380.0) - .open(&mut open) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "medsam2", + SLICE_PROP.title(slot), + &mut open, + detach::WinOpts::width(380.0).resizable(false), + |ui| { ui.label(format!( "Follows a structure boxed on one slice through the stack with MedSAM2, \ re-implemented natively in Rust. Drag a box around it in the {} view, on a \ @@ -964,7 +959,8 @@ impl ViewerApp { ui.separator(); ui.weak(status); } - }); + }, + ); if browse { if let Some(dir) = Self::pick_folder("Model folder") { diff --git a/src/app/chrome.rs b/src/app/chrome.rs index 264e319..238d6a4 100644 --- a/src/app/chrome.rs +++ b/src/app/chrome.rs @@ -11,6 +11,7 @@ impl ViewerApp { let mut reset_views = false; let mut open_gen = false; let mut open_models = false; + let mut open_pacs = false; let mut open_propagate = false; let mut open_drr = false; let mut open_export: Option = None; @@ -185,9 +186,15 @@ impl ViewerApp { .changed(); }); ui.menu_button("Tools", |ui| { - // The three segmentation engines, one block per dataset: - // the same four entries, in the same order, for A and B. - let tools: [(&ToolInfo, &str); 4] = [ + // One block per dataset: the same six tools, in the same + // order, for A and B. + let tools: [(&ToolInfo, &str); 6] = [ + ( + &COMBINE, + "Build one structure out of others: union, intersection, \ + subtraction or symmetric difference, with a margin on any of \ + them. Contours and segmentations mix freely.", + ), ( &BODY_CONTOUR, "Outline the patient and leave the couch, the chair and the \ @@ -212,6 +219,12 @@ impl ViewerApp { stack at full in-plane resolution (MedSAM2, re-implemented \ natively in Rust).", ), + ( + &MOTION, + "Register the reference phase of a 4D group to every other \ + phase, carry the targets across, and measure their motion — \ + trajectories, drift, correlations and the ITV.", + ), ]; let mut open_tool: Option<(usize, &ToolInfo)> = None; for slot in 0..SLOT_NAMES.len() { @@ -234,6 +247,12 @@ impl ViewerApp { } } match open_tool { + Some((slot, t)) if t.glyph == COMBINE.glyph => { + self.open_combine_dialog(slot, Vec::new()) + } + Some((slot, t)) if t.glyph == MOTION.glyph => { + self.open_motion_dialog(slot, None) + } Some((slot, t)) if t.glyph == BODY_CONTOUR.glyph => { self.open_body_dialog(slot) } @@ -260,6 +279,67 @@ impl ViewerApp { open_propagate = true; ui.close(); } + let both = self.slots[0].study.is_some() && self.slots[1].study.is_some(); + if ui + .add_enabled(both, egui::Button::new("◎ Transfer by relationship…")) + .on_hover_text( + "Place a structure into the other dataset at the same offset \ + from a reference structure (e.g. the heart) — the \ + target–reference relationship travels, not a registration", + ) + .clicked() + { + self.open_transfer_dialog(0); + ui.close(); + } + let any = self.slots[0].study.is_some() || self.slots[1].study.is_some(); + if ui + .add_enabled(any, egui::Button::new("◑ Compare structures…")) + .on_hover_text( + "Volumes, centroid offset, Dice, HD95 and mean surface \ + distance of any two structures — within a dataset or across \ + the two", + ) + .clicked() + { + self.open_compare_dialog(0); + ui.close(); + } + let has_dose = self + .slots + .iter() + .any(|s| s.study.as_ref().is_some_and(|st| !st.doses.is_empty())); + if ui + .add_enabled(has_dose, egui::Button::new("📊 Dose–volume histograms…")) + .on_hover_text( + "Cumulative and differential DVHs of any structures against \ + any loaded dose objects, with the metrics table, protocol \ + constraint checking and CSV export — in a window that can \ + go on its own monitor", + ) + .clicked() + { + let slot = usize::from( + self.slots[0].study.is_none() + || self.slots[0] + .study + .as_ref() + .is_some_and(|s| s.doses.is_empty()), + ); + self.open_dvh_dialog(slot.min(1), Vec::new()); + ui.close(); + } + if ui + .add_enabled( + !self.motion_reports.is_empty(), + egui::Button::new("📈 Motion results…"), + ) + .on_hover_text("The finished 4D motion runs of this session") + .clicked() + { + self.motion_results_open = true; + ui.close(); + } if ui .add_enabled( self.slots[0].study.is_some() || self.slots[1].study.is_some(), @@ -275,6 +355,18 @@ impl ViewerApp { open_drr = true; ui.close(); } + if ui + .button("🏥 PACS — patient archive…") + .on_hover_text( + "The local archive: every study filed here, ready to be taken \ + into a dataset and given back the structures and \ + segmentations drawn on it", + ) + .clicked() + { + open_pacs = true; + ui.close(); + } if ui .button("📦 Downloaded models…") .on_hover_text( @@ -363,6 +455,9 @@ impl ViewerApp { if open_gen { self.gen_open = true; } + if open_pacs { + self.open_pacs_window(); + } if open_models { self.open_models_window(); } diff --git a/src/app/combine_win.rs b/src/app/combine_win.rs new file mode 100644 index 0000000..fbdd262 --- /dev/null +++ b/src/app/combine_win.rs @@ -0,0 +1,723 @@ +//! The structure-algebra window: combining contours and segmentations. +//! +//! Its one job that the core module ([`crate::structops`]) cannot do is +//! deciding *what the operands are*. Everything else — the four operations, +//! the margins, the tidying — is arithmetic; picking "the GTV from the second +//! structure set of dataset A" out of a data tree, rasterizing it onto the +//! displayed lattice, and putting the answer back as whichever kind the user +//! wants is the part that has to know about the application. +//! +//! The operand list is ordered and the order is shown, because three of the +//! four operations are not commutative in the way people expect: `A − B − C` +//! is not `B − A − C`, and a subtraction with its operands the wrong way +//! round is the most common mistake this tool can make. Hence the ↑ ↓ arrows +//! and the summary line above the buttons that spells the recipe out. + +use crate::structops::{self, BoolOp, Cleanup, Combined, Margin, Operand, Recipe}; +use crate::volume::Grid; + +use super::*; + +/// Where one operand comes from: a structure set or a segmentation series of +/// the slot, and an item within it. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) struct ItemRef { + pub kind: SetKind, + /// Index of the set / series within the study. + pub set: usize, + /// Index of the structure / segment within it. + pub idx: usize, +} + +/// One row of the operand list. +pub(super) struct Row { + pub item: ItemRef, + pub margin: Margin, + /// Shown while the row is edited; the margin fields are per-direction + /// only when the user asks for them. + pub per_direction: bool, +} + +/// Where the answer goes. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum Output { + Segment, + Structure, +} + +impl Output { + fn label(self) -> &'static str { + match self { + Output::Segment => "a segmentation", + Output::Structure => "an RT structure", + } + } +} + +/// The window's state; it stays open across runs. +pub(super) struct CombineDialog { + pub slot: usize, + pub op: BoolOp, + pub rows: Vec, + pub margin: Margin, + pub margin_per_direction: bool, + pub cleanup: Cleanup, + pub name: String, + pub output: Output, + /// Interpreted type given to an RT structure result — PTV, ORGAN, … + pub roi_type: String, + pub status: Option, +} + +/// What a finished run hands back, with the identity of what it ran on. +pub struct CombineResult { + pub combined: Combined, + pub name: String, + pub output: Output, + pub roi_type: String, + pub volume_dims: [usize; 3], + pub frame_of_reference_uid: String, + pub elapsed_secs: f64, +} + +/// Everything a run needs, snapshotted when it starts. +struct CombineRequest { + recipe: Recipe, + grid: Grid, + name: String, + output: Output, + roi_type: String, +} + +/// The interpreted types offered for an RT structure result — the ones a +/// planning system actually branches on. +const ROI_TYPES: [&str; 7] = [ + "ORGAN", + "PTV", + "CTV", + "GTV", + "AVOIDANCE", + "EXTERNAL", + "CONTROL", +]; + +impl ViewerApp { + /// Every structure and segment of `slot` that can be an operand, as + /// (reference, label) — the pick list, and what the summary line names. + pub(super) fn combine_candidates(&self, slot: usize) -> Vec<(ItemRef, String)> { + let mut out = Vec::new(); + let Some(study) = self.slots[slot].study.as_ref() else { + return out; + }; + for (si, set) in study.structure_sets.iter().enumerate() { + for (ii, roi) in set.rois.iter().enumerate() { + out.push(( + ItemRef { + kind: SetKind::Structures, + set: si, + idx: ii, + }, + format!("{} / {}", set.label, roi.name), + )); + } + } + for (si, ser) in study.seg_series.iter().enumerate() { + for (ii, seg) in ser.segs.iter().enumerate() { + out.push(( + ItemRef { + kind: SetKind::Segmentations, + set: si, + idx: ii, + }, + format!("{} / {}", ser.label, seg.name), + )); + } + } + out + } + + fn combine_label(&self, slot: usize, item: ItemRef) -> String { + self.combine_candidates(slot) + .into_iter() + .find(|(r, _)| *r == item) + .map(|(_, l)| l) + .unwrap_or_else(|| "(gone)".to_string()) + } + + /// Rasterize one operand onto the displayed lattice. + /// + /// A contour is rasterized; a segment already on this lattice is taken as + /// it is; a segment on another lattice is resampled onto this one. The + /// third case is what makes it legal to combine a segmentation drawn on + /// one image series with a structure drawn on another. + fn operand_mask(&self, slot: usize, item: ItemRef, grid: &Grid) -> Option> { + let study = self.slots[slot].study.as_ref()?; + match item.kind { + SetKind::Structures => { + let roi = study.structure_sets.get(item.set)?.rois.get(item.idx)?; + segmentation::rasterize_roi(grid, roi) + } + SetKind::Segmentations => { + let ser = study.seg_series.get(item.set)?; + let seg = ser.segs.get(item.idx)?; + if ser.grid.dims == grid.dims { + Some(seg.mask.clone()) + } else { + Some(crate::dicomseg::resample_mask(&seg.mask, &ser.grid, grid)) + } + } + } + } + + /// Tools ▶ combine structures: open the window for `slot`, optionally + /// seeded with the items the tree had ticked. + pub(super) fn open_combine_dialog(&mut self, slot: usize, seed: Vec) { + if self.slots[slot].study.is_none() { + return; + } + let rows: Vec = seed + .into_iter() + .map(|item| Row { + item, + margin: Margin::NONE, + per_direction: false, + }) + .collect(); + match &mut self.combine_dialog { + Some(d) if self.combine_job.is_none() => { + d.slot = slot; + if !rows.is_empty() { + d.rows = rows; + } + } + Some(_) => {} + None => { + self.combine_dialog = Some(CombineDialog { + slot, + op: BoolOp::Union, + rows, + margin: Margin::NONE, + margin_per_direction: false, + cleanup: Cleanup::default(), + name: "Combined".to_string(), + output: Output::Segment, + roi_type: "ORGAN".to_string(), + status: None, + }); + } + } + } + + /// Rasterize every operand, snapshot the recipe and run it on a worker. + pub(super) fn start_combine(&mut self) { + if self.combine_job.is_some() { + return; + } + let Some(d) = &self.combine_dialog else { + return; + }; + let slot = d.slot; + let Some(study) = self.slots[slot].study.as_ref() else { + return; + }; + let grid = study.volume.grid(); + let mut operands = Vec::with_capacity(d.rows.len()); + for row in &d.rows { + let name = self.combine_label(slot, row.item); + match self.operand_mask(slot, row.item, &grid) { + Some(mask) => operands.push(Operand { + name, + mask, + margin: row.margin, + }), + None => { + // An empty contour rasterizes to nothing; saying so beats + // silently dropping it out of the recipe. + self.error = Some(format!( + "'{name}' has nothing on this image series, so the result would \ + not mean what it says. Remove it from the list or pick another." + )); + return; + } + } + } + let name = match d.name.trim() { + "" => "Combined".to_string(), + n => n.to_string(), + }; + let req = CombineRequest { + recipe: Recipe { + op: d.op, + operands, + margin: d.margin, + cleanup: d.cleanup, + }, + grid, + name, + output: d.output, + roi_type: d.roi_type.clone(), + }; + let progress = Arc::new(Progress::default()); + progress.set("Preparing…"); + self.combine_slot = slot; + self.combine_job = Some(Job::spawn(progress, move |p| { + let t0 = std::time::Instant::now(); + let r = structops::combine(&req.recipe, &req.grid, p).map(|combined| CombineResult { + combined, + name: req.name.clone(), + output: req.output, + roi_type: req.roi_type.clone(), + volume_dims: req.grid.dims, + frame_of_reference_uid: req.grid.frame_of_reference_uid.clone(), + elapsed_secs: t0.elapsed().as_secs_f64(), + }); + (slot, r) + })); + } + + /// A run finished: land it as a segment or as an RT structure. + pub(super) fn on_combine_done(&mut self, slot: usize, result: CombineResult) { + if !self.slot_still_shows(slot, result.volume_dims, &result.frame_of_reference_uid) { + self.error = Some(stale_result(&COMBINE)); + return; + } + if result.combined.voxels == 0 { + self.error = Some(format!( + "'{}' came out empty. Check the order of the list — a subtraction with \ + its operands the wrong way round is the usual reason.", + result.name + )); + return; + } + let idx = self.add_segmentation( + slot, + result.name.clone(), + result.volume_dims, + &result.combined.mask, + ); + if result.output == Output::Structure { + self.seg_to_rtstruct(slot, idx, &result.roi_type); + // The mask was only the vehicle; the user asked for contours. + if let Some(segs) = self.slots[slot].segs_mut() { + if idx < segs.len() { + segs.remove(idx); + } + } + self.slots[slot].active_seg = 0; + } + let pieces = match result.combined.pieces { + 0 | 1 => String::new(), + n => format!(", in {n} separate pieces"), + }; + if let Some(d) = &mut self.combine_dialog { + d.status = Some(format!( + "✔ {} → {}: {:.1} cm³{pieces} in {:.1} s", + result.name, + result.output.label(), + result.combined.cm3, + result.elapsed_secs + )); + } + self.settings_gen += 1; + } + + /// The tool window. + pub(super) fn combine_window(&mut self, ctx: &egui::Context) { + let Some(slot) = self.combine_dialog.as_ref().map(|d| d.slot) else { + return; + }; + if self.slots[slot].study.is_none() { + self.combine_dialog = None; + return; + } + // Settled before the dialog is borrowed mutably for the frame. + let candidates = self.combine_candidates(slot); + let labels: Vec = self + .combine_dialog + .as_ref() + .map(|d| { + d.rows + .iter() + .map(|r| self.combine_label(slot, r.item)) + .collect() + }) + .unwrap_or_default(); + let Some(d) = &mut self.combine_dialog else { + return; + }; + let running = self + .combine_job + .as_ref() + .filter(|_| self.combine_slot == slot); + let mut open = true; + let (mut run, mut close, mut cancel) = (false, false, false); + let mut move_row: Option<(usize, isize)> = None; + let mut drop_row: Option = None; + detach::tool_window( + ctx, + "combine", + COMBINE.title(slot), + &mut open, + detach::WinOpts::width(470.0).resizable(false), + |ui| { + ui.label( + "Builds one structure out of others: union, intersection, subtraction \ + or symmetric difference, with a margin on any of them. Contours and \ + segmentations mix freely — each is rasterized onto the displayed \ + series first.", + ); + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Operation:"); + egui::ComboBox::from_id_salt("combine_op") + .selected_text(d.op.label()) + .show_ui(ui, |ui| { + for o in BoolOp::ALL { + ui.selectable_value(&mut d.op, o, o.label()); + } + }); + }); + if d.op == BoolOp::Subtract { + ui.weak("The first row is what the rest are taken out of."); + } + + ui.add_space(4.0); + if candidates.is_empty() { + ui.label( + egui::RichText::new( + "This dataset has no structures or segments to combine yet.", + ) + .color(warn_color(ui.visuals())), + ); + } + // ---- the operand list -------------------------------- + let n_rows = d.rows.len(); + for (i, row) in d.rows.iter_mut().enumerate() { + ui.push_id(i, |ui| { + ui.horizontal(|ui| { + ui.label(format!("{}.", i + 1)); + let current = labels.get(i).cloned().unwrap_or_default(); + egui::ComboBox::from_id_salt("pick") + .selected_text(shorten(¤t)) + .width(210.0) + .show_ui(ui, |ui| { + for (r, label) in &candidates { + ui.selectable_value(&mut row.item, *r, label); + } + }); + if !row.per_direction { + let mut mm = row.margin.right; + if ui + .add( + egui::DragValue::new(&mut mm) + .range(-200.0..=200.0) + .speed(0.5) + .prefix("margin ") + .suffix(" mm"), + ) + .on_hover_text( + "Grow (+) or shrink (−) this operand before it is \ + combined. A crop is an intersection whose second \ + operand was shrunk.", + ) + .changed() + { + row.margin = Margin::uniform(mm); + } + } else { + ui.weak(row.margin.describe()); + } + if ui + .selectable_label(row.per_direction, "R/L/A/P/S/I") + .on_hover_text("Give the margin a value per patient direction") + .clicked() + { + row.per_direction = !row.per_direction; + } + if ui.button("↑").clicked() && i > 0 { + move_row = Some((i, -1)); + } + if ui.button("↓").clicked() && i + 1 < n_rows { + move_row = Some((i, 1)); + } + if ui.button("✕").clicked() { + drop_row = Some(i); + } + }); + if row.per_direction { + directional_margin(ui, &mut row.margin); + } + }); + } + ui.horizontal(|ui| { + if ui + .add_enabled(!candidates.is_empty(), egui::Button::new("➕ Add")) + .clicked() + { + d.rows.push(Row { + item: candidates[0].0, + margin: Margin::NONE, + per_direction: false, + }); + } + if ui.button("Clear").clicked() { + d.rows.clear(); + } + }); + + ui.separator(); + ui.collapsing("Result", |ui| { + ui.horizontal(|ui| { + ui.label("Margin on the result:"); + if !d.margin_per_direction { + let mut mm = d.margin.right; + if ui + .add( + egui::DragValue::new(&mut mm) + .range(-200.0..=200.0) + .speed(0.5) + .suffix(" mm"), + ) + .changed() + { + d.margin = Margin::uniform(mm); + } + } else { + ui.weak(d.margin.describe()); + } + if ui + .selectable_label(d.margin_per_direction, "R/L/A/P/S/I") + .clicked() + { + d.margin_per_direction = !d.margin_per_direction; + } + }); + if d.margin_per_direction { + directional_margin(ui, &mut d.margin); + } + ui.checkbox(&mut d.cleanup.fill_holes, "Fill interior cavities") + .on_hover_text( + "Slice by slice, so a lung that drains through the trachea \ + still closes.", + ); + ui.horizontal(|ui| { + ui.label("Smooth:"); + ui.add( + egui::Slider::new(&mut d.cleanup.close_mm, 0.0..=10.0) + .suffix(" mm") + .fixed_decimals(1), + ) + .on_hover_text("A closing, to take the staircase off the surface."); + }); + ui.checkbox(&mut d.cleanup.keep_largest, "Keep only the largest piece") + .on_hover_text( + "Useful after a subtraction that leaves slivers; destructive \ + on anything genuinely paired, like two lungs.", + ); + ui.add_enabled_ui(!d.cleanup.keep_largest, |ui| { + ui.horizontal(|ui| { + ui.label("…or drop pieces under:"); + ui.add( + egui::DragValue::new(&mut d.cleanup.min_volume_cm3) + .range(0.0..=1000.0) + .speed(0.1) + .suffix(" cm³"), + ); + }); + }); + }); + + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(140.0)); + ui.label("as"); + egui::ComboBox::from_id_salt("combine_out") + .selected_text(d.output.label()) + .width(130.0) + .show_ui(ui, |ui| { + for o in [Output::Segment, Output::Structure] { + ui.selectable_value(&mut d.output, o, o.label()); + } + }); + if d.output == Output::Structure { + egui::ComboBox::from_id_salt("combine_roi_type") + .selected_text(&d.roi_type) + .width(110.0) + .show_ui(ui, |ui| { + for t in ROI_TYPES { + ui.selectable_value(&mut d.roi_type, t.to_string(), t); + } + }); + } + }); + + ui.separator(); + // The recipe, spelled out — the cheapest possible guard + // against an operand list in the wrong order. + ui.label(egui::RichText::new(recipe_line(d, &labels)).italics()); + ui.separator(); + match running { + Some(job) => cancel = progress_row(ui, &job.progress), + None => { + ui.horizontal(|ui| { + let ready = d.rows.len() > usize::from(d.op != BoolOp::Union); + if ui + .add_enabled(ready, egui::Button::new("▶ Combine")) + .on_hover_text("Evaluate the recipe on the displayed series") + .clicked() + { + run = true; + } + if ui.button("Close").clicked() { + close = true; + } + }); + } + } + if let Some(status) = &d.status { + ui.separator(); + ui.weak(status); + } + }, + ); + if let Some((i, delta)) = move_row { + let j = (i as isize + delta) as usize; + if let Some(d) = &mut self.combine_dialog { + if j < d.rows.len() { + d.rows.swap(i, j); + } + } + } + if let Some(i) = drop_row { + if let Some(d) = &mut self.combine_dialog { + if i < d.rows.len() { + d.rows.remove(i); + } + } + } + if cancel { + if let Some(job) = &self.combine_job { + job.progress.cancel(); + } + } + if run { + self.start_combine(); + } + if !open || close { + self.combine_dialog = None; + } + } +} + +/// `PTV ∪ Nodes − (Cord + 5 mm)` — the recipe as one line of text. +fn recipe_line(d: &CombineDialog, labels: &[String]) -> String { + if d.rows.is_empty() { + return "Nothing selected yet.".to_string(); + } + let mut parts: Vec = Vec::with_capacity(d.rows.len()); + for (i, row) in d.rows.iter().enumerate() { + let name = shorten(labels.get(i).map(String::as_str).unwrap_or("?")); + parts.push(if row.margin.is_none() { + name + } else { + format!("({name} {})", row.margin.describe()) + }); + } + let mut line = parts.join(&format!(" {} ", d.op.joiner())); + if !d.margin.is_none() { + line = format!("({line}) {}", d.margin.describe()); + } + format!( + "{} = {line}", + if d.name.trim().is_empty() { + "result" + } else { + d.name.trim() + } + ) +} + +/// The last path component, so a long "Set / Structure" still fits a combo. +fn shorten(label: &str) -> String { + label.rsplit(" / ").next().unwrap_or(label).to_string() +} + +/// Six drag fields, laid out the way a planning system asks for them. +fn directional_margin(ui: &mut egui::Ui, m: &mut Margin) { + ui.horizontal(|ui| { + ui.add_space(18.0); + for (label, value, hint) in [ + ("R", &mut m.right, "toward the patient's right"), + ("L", &mut m.left, "toward the patient's left"), + ("A", &mut m.anterior, "anterior"), + ("P", &mut m.posterior, "posterior"), + ("S", &mut m.superior, "superior"), + ("I", &mut m.inferior, "inferior"), + ] { + ui.add( + egui::DragValue::new(value) + .range(-200.0..=200.0) + .speed(0.5) + .prefix(format!("{label} ")) + .suffix("mm"), + ) + .on_hover_text(hint); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_tool_names_itself_like_the_others() { + assert_eq!(COMBINE.title(0), "◧ Combine structures — dataset A"); + assert_eq!(COMBINE.menu_entry(1), "◧ Combine structures in dataset B…"); + assert_eq!(COMBINE.short_button(), "◧ Combine"); + } + + #[test] + fn the_recipe_line_spells_out_order_and_margins() { + let d = CombineDialog { + slot: 0, + op: BoolOp::Subtract, + rows: vec![ + Row { + item: ItemRef { + kind: SetKind::Structures, + set: 0, + idx: 0, + }, + margin: Margin::NONE, + per_direction: false, + }, + Row { + item: ItemRef { + kind: SetKind::Structures, + set: 0, + idx: 1, + }, + margin: Margin::uniform(5.0), + per_direction: false, + }, + ], + margin: Margin::NONE, + margin_per_direction: false, + cleanup: Cleanup::default(), + name: "PTV_eval".into(), + output: Output::Segment, + roi_type: "ORGAN".into(), + status: None, + }; + let labels = vec!["Set 1 / PTV".to_string(), "Set 1 / Cord".to_string()]; + assert_eq!(recipe_line(&d, &labels), "PTV_eval = PTV − (Cord +5.0 mm)"); + } + + #[test] + fn a_long_set_name_is_shortened_to_the_structure() { + assert_eq!(shorten("Structure Set 1 / Lung_L"), "Lung_L"); + assert_eq!(shorten("Lung_L"), "Lung_L"); + } +} diff --git a/src/app/compare_win.rs b/src/app/compare_win.rs new file mode 100644 index 0000000..bdce853 --- /dev/null +++ b/src/app/compare_win.rs @@ -0,0 +1,237 @@ +//! *Tools ▶ Compare structures*: geometric comparison of any two +//! structures — volumes, centroids and their offset, Dice, HD95 and mean +//! surface distance. +//! +//! The two structures may live in either dataset and on different lattices; +//! the second is resampled onto the first's grid through patient +//! coordinates. Across two datasets that is only meaningful when both are +//! in the same frame of reference (or have been registered and propagated +//! first) — the window says so instead of silently comparing apples to +//! oranges. + +use crate::motion; +use crate::volume::Grid; + +use super::combine_win::ItemRef; +use super::*; + +/// The window's state. +pub(super) struct CompareDialog { + pub slot_a: usize, + pub item_a: Option, + pub slot_b: usize, + pub item_b: Option, + /// The last computation, as printable lines. + pub result: Vec, +} + +impl ViewerApp { + pub(super) fn open_compare_dialog(&mut self, slot: usize) { + self.compare_dialog = Some(CompareDialog { + slot_a: slot, + item_a: None, + slot_b: slot, + item_b: None, + result: Vec::new(), + }); + } + + /// One structure's mask on a definite grid, with its identity — the + /// common currency of the compare and transfer tools. A contour is + /// rasterized onto the displayed volume of its slot; a segment comes on + /// its own series' lattice. + pub(super) fn item_mask_grid( + &self, + slot: usize, + item: ItemRef, + ) -> Option<(Vec, Grid, String, [u8; 3])> { + let study = self.slots[slot].study.as_ref()?; + match item.kind { + SetKind::Structures => { + let roi = study.structure_sets.get(item.set)?.rois.get(item.idx)?; + let grid = study.volume.grid(); + let mask = segmentation::rasterize_roi(&grid, roi)?; + Some((mask, grid, roi.name.clone(), roi.color)) + } + SetKind::Segmentations => { + let ser = study.seg_series.get(item.set)?; + let seg = ser.segs.get(item.idx)?; + Some(( + seg.mask.clone(), + ser.grid.clone(), + seg.name.clone(), + seg.color, + )) + } + } + } + + fn compare_now(&mut self) { + let Some(d) = &self.compare_dialog else { + return; + }; + let (slot_a, slot_b) = (d.slot_a, d.slot_b); + let pick = |slot: usize, sel: Option| -> Option<(ItemRef, String)> { + let cands = self.combine_candidates(slot); + sel.and_then(|i| cands.get(i).cloned()) + }; + let (Some((ia, la)), Some((ib, lb))) = (pick(slot_a, d.item_a), pick(slot_b, d.item_b)) + else { + if let Some(d) = &mut self.compare_dialog { + d.result = vec!["Pick two structures first.".into()]; + } + return; + }; + let (Some((ma, ga, _, _)), Some((mb, gb, _, _))) = ( + self.item_mask_grid(slot_a, ia), + self.item_mask_grid(slot_b, ib), + ) else { + if let Some(d) = &mut self.compare_dialog { + d.result = vec!["One of the structures is gone or empty.".into()]; + } + return; + }; + let mut lines = Vec::new(); + if ga.frame_of_reference_uid != gb.frame_of_reference_uid { + lines.push( + "⚠ Different frames of reference — the comparison assumes the patient \ + coordinates already correspond (register + propagate first if they do not)." + .into(), + ); + } + let mb_on_a = if gb.matches(&ga) { + mb + } else { + crate::dicomseg::resample_mask(&mb, &gb, &ga) + }; + match motion::overlap(&ma, &mb_on_a, &ga) { + Some(o) => { + lines.push(format!("A: {la} — {:.2} cm³", o.vol_a_cm3)); + lines.push(format!("B: {lb} — {:.2} cm³", o.vol_b_cm3)); + if let Some(s) = o.centroid_shift() { + lines.push(format!( + "Centroid offset A → B: RL {:+.2} · AP {:+.2} · SI {:+.2} mm (|d| = {:.2} mm)", + s.x, + s.y, + s.z, + s.length() + )); + } + lines.push(format!("Dice: {:.3}", o.dice)); + lines.push(format!("HD95: {:.2} mm", o.hd95_mm)); + lines.push(format!("Mean surface distance: {:.2} mm", o.msd_mm)); + } + None => lines.push( + "Nothing to compare — one of the masks is empty (a structure from the other \ + dataset may lie outside this volume; resampling cannot invent it)." + .into(), + ), + } + if let Some(d) = &mut self.compare_dialog { + d.result = lines; + } + } + + pub(super) fn compare_window(&mut self, ctx: &egui::Context) { + let Some(d) = &self.compare_dialog else { + return; + }; + let both = [d.slot_a, d.slot_b]; + let cands: [Vec; 2] = [ + self.combine_candidates(both[0]) + .into_iter() + .map(|(_, l)| l) + .collect(), + self.combine_candidates(both[1]) + .into_iter() + .map(|(_, l)| l) + .collect(), + ]; + let comparison = self.comparison; + let mut compute = false; + let mut close = false; + let mut open = true; + let d = self.compare_dialog.as_mut().expect("checked above"); + detach::tool_window( + ctx, + "compare", + "◑ Compare structures", + &mut open, + detach::WinOpts::default().resizable(false), + |ui| { + ui.label( + "Volumes, centroid offset, Dice, HD95 and mean surface distance of \ + any two structures.", + ); + ui.add_space(4.0); + let row = |ui: &mut egui::Ui, + what: &str, + slot: &mut usize, + item: &mut Option, + list: &[String], + salt: &str| { + ui.horizontal(|ui| { + ui.label(what); + if comparison { + for (s, name) in SLOT_NAMES.iter().enumerate() { + if ui.selectable_label(*slot == s, *name).clicked() { + *slot = s; + *item = None; + } + } + } + let sel = item + .and_then(|i| list.get(i).cloned()) + .unwrap_or_else(|| "(pick)".into()); + egui::ComboBox::from_id_salt(salt.to_string()) + .width(260.0) + .selected_text(sel) + .show_ui(ui, |ui| { + for (i, l) in list.iter().enumerate() { + ui.selectable_value(item, Some(i), l); + } + }); + }); + }; + // The candidate lists were computed for the slots as they + // were at the top of the frame; after a slot switch the next + // frame refreshes them, so clear the pick to stay in bounds. + row( + ui, + "Structure 1:", + &mut d.slot_a, + &mut d.item_a, + &cands[0], + "cmp_a", + ); + row( + ui, + "Structure 2:", + &mut d.slot_b, + &mut d.item_b, + &cands[1], + "cmp_b", + ); + ui.add_space(4.0); + for line in &d.result { + ui.label(line.clone()); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Compare").clicked() { + compute = true; + } + if ui.button("Close").clicked() { + close = true; + } + }); + }, + ); + if compute { + self.compare_now(); + } + if close || !open { + self.compare_dialog = None; + } + } +} diff --git a/src/app/d3.rs b/src/app/d3.rs index 158f8e5..baaaa02 100644 --- a/src/app/d3.rs +++ b/src/app/d3.rs @@ -273,12 +273,13 @@ impl ViewerApp { let registered = self.registration.is_some(); let title = format!("3D structures — dataset {}", SLOT_NAMES[w.slot]); let mut open = w.open; - egui::Window::new(title) - .id(egui::Id::new(("d3_win", w.slot))) - .open(&mut open) - .default_size([640.0, 700.0]) - .resizable(true) - .show(ctx, |ui| { + detach::tool_window( + ctx, + &format!("d3_{}", w.slot), + title, + &mut open, + detach::WinOpts::size(640.0, 700.0).no_scroll(), + |ui| { if let Some(job) = &w.job { ui.horizontal(|ui| { ui.spinner(); @@ -567,9 +568,7 @@ impl ViewerApp { (p.x as f32 - c[0], p.y as f32 - c[1], p.z as f32 - c[2]); let x1 = cy * x - sy * y; let y1 = sy * x + cy * y; - let y2 = cp * y1 - sp * z; let z2 = sp * y1 + cp * z; - let _ = y2; Pos2::new(cx + x1 * scale, cyc - z2 * scale) }; let max = field.max_mag.max(1e-6) as f32; @@ -611,7 +610,8 @@ impl ViewerApp { FontId::proportional(11.0), Color32::GRAY, ); - }); + }, + ); w.open = open; } windows.retain(|w| w.open); diff --git a/src/app/detach.rs b/src/app/detach.rs new file mode 100644 index 0000000..dc755b2 --- /dev/null +++ b/src/app/detach.rs @@ -0,0 +1,293 @@ +//! Tool windows that can step outside the main window. +//! +//! Every secondary window — the archive, the model manager, the DRR, the 3D +//! scenes, the segmentation tools — is drawn through [`tool_window`]. Docked, +//! it is an ordinary [`egui::Window`] floating over the viewports, which is +//! where a single-screen user wants it. Detached, the same contents are drawn +//! into an *immediate viewport*: a real top-level window of the operating +//! system that can be dragged onto a second or third monitor, maximized +//! there, and left open while the main window keeps the images. Nothing about +//! the contents changes — the same closure runs in both cases — so a window +//! can be moved back and forth mid-run. +//! +//! Three things make this work in practice: +//! +//! * the choice is per window and remembered (see [`detached_ids`], which the +//! application writes to its settings file), so a reading room that always +//! wants the archive on the right-hand screen gets it there on every start; +//! * the size and position of a detached window are remembered for the +//! session, so closing and reopening it puts it back on the same monitor +//! rather than on the main one; +//! * when the backend cannot give us native windows at all, egui says so +//! through [`egui::ViewportClass::EmbeddedWindow`] and the window simply +//! stays inside the main one instead of vanishing. + +use std::collections::BTreeSet; + +/// egui-memory key of the set of detached window ids. +const DETACHED: &str = "detached_tool_windows"; +/// egui-memory key prefix of one window's remembered geometry. +const GEOM: &str = "tool_window_geometry"; + +/// How the window looks while it is docked, and how big its own window opens. +#[derive(Clone, Copy)] +pub(super) struct WinOpts { + /// Default outer size. A height of `0.0` means "as tall as the contents" + /// while docked; the detached window then opens at `DEFAULT_TALL`. + pub size: [f32; 2], + pub resizable: bool, + pub collapsible: bool, + /// Docked, this window is pinned to the middle of the main window (the + /// dialog-like tools do this so they cannot be lost behind the views). + pub center: bool, + /// Scroll the contents when the window is its own window and the user + /// has made it smaller than they are. Off for the windows that answer + /// the mouse wheel themselves (the image and 3-D views), where a scroll + /// area would fight them for it. + pub scroll: bool, +} + +/// A native window with no height of its own opens this tall. +const DEFAULT_TALL: f32 = 620.0; + +impl Default for WinOpts { + fn default() -> Self { + Self { + size: [420.0, 0.0], + resizable: true, + collapsible: true, + center: false, + scroll: true, + } + } +} + +impl WinOpts { + pub(super) fn width(w: f32) -> Self { + Self { + size: [w, 0.0], + ..Self::default() + } + } + + pub(super) fn size(w: f32, h: f32) -> Self { + Self { + size: [w, h], + ..Self::default() + } + } + + pub(super) fn resizable(mut self, yes: bool) -> Self { + self.resizable = yes; + self + } + + pub(super) fn collapsible(mut self, yes: bool) -> Self { + self.collapsible = yes; + self + } + + pub(super) fn centered(mut self) -> Self { + self.center = true; + self + } + + pub(super) fn no_scroll(mut self) -> Self { + self.scroll = false; + self + } +} + +/// One detached window's last geometry, so it reopens where it was left — +/// on the monitor it was left on. +#[derive(Clone, Copy, Default)] +struct Geometry { + pos: Option<[f32; 2]>, + size: Option<[f32; 2]>, +} + +/// The ids of the windows the user has pulled out, as the application stores +/// them between runs. +pub(super) fn detached_ids(ctx: &egui::Context) -> BTreeSet { + ctx.data(|d| d.get_temp::>(egui::Id::new(DETACHED))) + .unwrap_or_default() +} + +/// Seed the set from the settings file at start-up. +pub(super) fn set_detached_ids(ctx: &egui::Context, ids: BTreeSet) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(DETACHED), ids)); +} + +fn is_detached(ctx: &egui::Context, id: &str) -> bool { + detached_ids(ctx).contains(id) +} + +fn set_detached(ctx: &egui::Context, id: &str, yes: bool) { + let mut ids = detached_ids(ctx); + if yes { + ids.insert(id.to_owned()); + } else { + ids.remove(id); + } + set_detached_ids(ctx, ids); +} + +fn geometry(ctx: &egui::Context, id: &str) -> Geometry { + ctx.data(|d| d.get_temp::(egui::Id::new((GEOM, id)))) + .unwrap_or_default() +} + +fn set_geometry(ctx: &egui::Context, id: &str, g: Geometry) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new((GEOM, id)), g)); +} + +/// The one-line header every tool window carries: the button that moves it +/// out of the main window and back in. +fn detach_row(ui: &mut egui::Ui, out: &mut bool) { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (label, tip) = if *out { + ( + "Dock", + "Put this window back inside the main window.\n\ + Closing it instead only closes the tool — it opens in its own \ + window again next time.", + ) + } else { + ( + "Detach", + "Give this window its own window of the operating system — \ + drag it onto a second monitor, resize it there, and it stays \ + open beside the images. It reopens where you left it, and the \ + choice is remembered between runs.", + ) + }; + if ui.small_button(label).on_hover_text(tip).clicked() { + *out = !*out; + } + }); + }); + ui.separator(); +} + +/// Show one tool window, docked or in its own window of the operating +/// system, and run `contents` in whichever of the two it ended up in. +/// +/// `id` must be stable and unique — it keys the detached-window set, the +/// remembered geometry and the native window itself. `open` is cleared when +/// the user closes the window either way. +pub(super) fn tool_window( + ctx: &egui::Context, + id: &str, + title: impl Into, + open: &mut bool, + opts: WinOpts, + contents: impl FnOnce(&mut egui::Ui) -> R, +) -> Option { + if !*open { + return None; + } + let title = title.into(); + let mut out = is_detached(ctx, id); + let was_out = out; + let mut ret = None; + + if out { + let geom = geometry(ctx, id); + let size = geom.size.unwrap_or([ + opts.size[0].max(320.0), + if opts.size[1] > 0.0 { + opts.size[1] + } else { + DEFAULT_TALL + }, + ]); + let mut builder = egui::ViewportBuilder::default() + .with_title(&title) + .with_inner_size(size); + if let Some(pos) = geom.pos { + builder = builder.with_position(pos); + } + // `FnOnce` contents, called from egui's `FnMut` callback: the option + // hands it over exactly once, on the pass that actually draws. + let mut contents = Some(contents); + let mut close = false; + let mut new_geom = None; + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of(("tool_window", id)), + builder, + |ui, class| { + // No native windows from this backend: draw the contents in + // the window egui made for us instead of losing them. + if class == egui::ViewportClass::EmbeddedWindow { + out = false; + } + detach_row(ui, &mut out); + if opts.scroll { + egui::ScrollArea::both() + .auto_shrink([false, false]) + .show(ui, |ui| { + if let Some(c) = contents.take() { + ret = Some(c(ui)); + } + }); + } else if let Some(c) = contents.take() { + ret = Some(c(ui)); + } + ui.ctx().input(|i| { + let info = i.viewport(); + if info.close_requested() { + close = true; + } + // Remember where the user put it — including which + // monitor, since the position is in desktop coordinates. + if let Some(outer) = info.outer_rect { + new_geom = Some(Geometry { + pos: Some([outer.min.x, outer.min.y]), + size: info + .inner_rect + .map(|r| [r.width(), r.height()]) + .or(Some([outer.width(), outer.height()])), + }); + } + }); + }, + ); + if let Some(g) = new_geom { + set_geometry(ctx, id, g); + } + if close { + *open = false; + } + } else { + let mut still_open = true; + let mut win = egui::Window::new(&title) + .id(egui::Id::new(id)) + .open(&mut still_open) + .collapsible(opts.collapsible) + .resizable(opts.resizable); + win = if opts.size[1] > 0.0 { + win.default_size(opts.size) + } else { + win.default_width(opts.size[0]) + }; + if opts.center { + win = win.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO); + } + win.show(ctx, |ui| { + detach_row(ui, &mut out); + ret = Some(contents(ui)); + }); + if !still_open { + *open = false; + } + } + + if out != was_out { + set_detached(ctx, id, out); + // The window that is being left behind would otherwise keep its old + // size and position for one more pass. + ctx.request_repaint(); + } + ret +} diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 1f89b2a..56d147e 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -42,6 +42,7 @@ impl ViewerApp { self.generator_window(ctx); self.anonymize_window(ctx); self.models_window(ctx); + self.pacs_window(ctx); self.propagate_window(ctx); self.drr_window(ctx); self.export_window(ctx); @@ -49,6 +50,12 @@ impl ViewerApp { self.autoseg_run_window(ctx); self.segvol_window(ctx); self.body_window(ctx); + self.combine_window(ctx); + self.dvh_window(ctx); + self.motion_window(ctx); + self.motion_results_window(ctx); + self.transfer_window(ctx); + self.compare_window(ctx); self.medsam2_window(ctx); self.autoseg_result_window(ctx); if let Some(msg) = self.notice.clone() { @@ -102,13 +109,13 @@ impl ViewerApp { &models::root_from_setting(&self.models_dir), models::Engine::TotalSegmentator, ); - egui::Window::new(AUTOSEG.title(d.slot)) - .id(egui::Id::new("autoseg_window")) - .collapsible(true) - .resizable(false) - .default_width(380.0) - .open(&mut open) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "autoseg", + AUTOSEG.title(d.slot), + &mut open, + detach::WinOpts::width(380.0).resizable(false), + |ui| { ui.label( "Segments the CT into up to 117 anatomical structures with \ TotalSegmentator's nnU-Net models, re-implemented natively in Rust.", @@ -195,7 +202,8 @@ impl ViewerApp { }); } } - }); + }, + ); if browse { if let Some(dir) = Self::pick_folder("Model folder") { self.models_dir = dir.display().to_string(); @@ -225,12 +233,13 @@ impl ViewerApp { let mut close_clicked = false; let mut apply_clicked = false; let vol_bytes = p.result.volume_dims[0] * p.result.volume_dims[1] * p.result.volume_dims[2]; - egui::Window::new(AUTOSEG.titled("results", p.slot)) - .collapsible(false) - .resizable(true) - .anchor(Align2::CENTER_CENTER, Vec2::ZERO) - .open(&mut open) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "autoseg_results", + AUTOSEG.titled("results", p.slot), + &mut open, + detach::WinOpts::default().collapsible(false).centered(), + |ui| { ui.label(format!( "{} structures found on dataset {} — {} · {:.0} s", p.result.organs.len(), @@ -297,7 +306,8 @@ impl ViewerApp { close_clicked = true; } }); - }); + }, + ); if apply_clicked && !close_clicked { self.apply_autoseg_selection(); } else if !open || close_clicked { @@ -318,12 +328,16 @@ impl ViewerApp { let mut reset_dir = false; let mut reset_params = false; - egui::Window::new("🧪 Generate synthetic RT test study") - .open(&mut open) - .collapsible(false) - .resizable(false) - .anchor(Align2::CENTER_CENTER, Vec2::ZERO) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "generator", + "🧪 Generate synthetic RT test study", + &mut open, + detach::WinOpts::default() + .resizable(false) + .collapsible(false) + .centered(), + |ui| { ui.set_max_width(560.0); ui.label( "Writes a self-contained test study: 40-slice CT water phantom with a \ @@ -447,7 +461,8 @@ impl ViewerApp { ui.add_space(4.0); ui.label(msg); } - }); + }, + ); self.gen_open = open; if browse { @@ -505,13 +520,15 @@ impl ViewerApp { let mut do_scan = false; let mut do_apply = false; - egui::Window::new("🔏 Anonymize DICOM folder") - .open(&mut open) - .collapsible(false) - .resizable(true) - .default_size([780.0, 560.0]) - .anchor(Align2::CENTER_CENTER, Vec2::ZERO) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "anonymize", + "🔏 Anonymize DICOM folder", + &mut open, + detach::WinOpts::size(780.0, 560.0) + .collapsible(false) + .centered(), + |ui| { ui.label( "Scans a folder, shows every identifying tag with its current values \ and a proposed replacement (editable), then rewrites the files. \ @@ -696,7 +713,8 @@ impl ViewerApp { } }); } - }); + }, + ); if !open { // Closing the window forgets everything that was scanned — the @@ -749,13 +767,15 @@ impl ViewerApp { let mut do_export = false; let mut reset_all = false; - egui::Window::new(format!("💾 Export dataset {} as DICOM", SLOT_NAMES[slot])) - .open(&mut open) - .collapsible(false) - .resizable(true) - .default_size([720.0, 520.0]) - .anchor(Align2::CENTER_CENTER, Vec2::ZERO) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "export", + format!("💾 Export dataset {} as DICOM", SLOT_NAMES[slot]), + &mut open, + detach::WinOpts::size(720.0, 520.0) + .collapsible(false) + .centered(), + |ui| { ui.label( "Writes the displayed volume (one file per slice) plus every \ structure set, segmentation series (as DICOM SEG), dose grid \ @@ -875,7 +895,8 @@ impl ViewerApp { ui.label(msg); } }); - }); + }, + ); // A running export is not aborted when the window closes — the // background thread finishes writing; only its message is dropped. diff --git a/src/app/drr_win.rs b/src/app/drr_win.rs index 44d34e1..5c6e9d0 100644 --- a/src/app/drr_win.rs +++ b/src/app/drr_win.rs @@ -155,13 +155,13 @@ impl ViewerApp { let running = self.drr_job.is_some(); self.refresh_drr_textures(ctx, &mut d); - egui::Window::new(format!("☢ DRR — dataset {}", SLOT_NAMES[d.slot])) - .id(egui::Id::new("drr_window")) - .collapsible(true) - .resizable(true) - .default_width(720.0) - .open(&mut open) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "drr", + format!("☢ DRR — dataset {}", SLOT_NAMES[d.slot]), + &mut open, + detach::WinOpts::width(720.0).no_scroll(), + |ui| { ui.label( "A digitally reconstructed radiograph: the line integral of \ attenuation from a point source through the CT onto a flat \ @@ -410,7 +410,8 @@ impl ViewerApp { }); } }); - }); + }, + ); if set_iso { if let Some(study) = &self.slots[d.slot].study { diff --git a/src/app/dvh_win.rs b/src/app/dvh_win.rs new file mode 100644 index 0000000..8a0150f --- /dev/null +++ b/src/app/dvh_win.rs @@ -0,0 +1,1074 @@ +//! *Tools ▶ 📊 Dose–volume histograms*: the plot, the table and the +//! constraint check, in a window that can be put on its own monitor. +//! +//! The window is deliberately a *review* tool rather than a dialog. It stays +//! open, recomputes when the picks change, and shows three things at once +//! because that is how a plan is actually read: the curves for shape, the +//! table for the numbers a report quotes, and — when a protocol is loaded — +//! the pass/fail column that says whether the plan is acceptable. +//! +//! Two design points worth stating, because both are easy to get wrong in a +//! way nobody notices: +//! +//! * **Every curve names its dose object.** Overlaying two plans is the +//! reason to allow more than one, and a legend that says only "Cord" twice +//! is worse than no legend. Structures keep their own colour and the dose +//! object picks the line style, so the eye groups by structure and reads +//! the comparison along each colour. +//! +//! * **A structure sticking out of the dose grid is called out.** Those +//! voxels are counted at zero dose, which drags the curve down and is the +//! honest reading — but silently, it looks like a cold structure rather +//! than a truncated calculation, so the window says so in the table and +//! in a warning line. +//! +//! The plot is drawn with the painter rather than a plotting crate: axes, +//! ticks, polylines and a hover readout are a hundred lines, and the +//! alternative is a dependency whose styling would have to be fought into +//! agreement with the rest of the interface anyway. + +use crate::dvh::{self, Constraint, Dvh, DvhParams, Metric}; +use crate::progress::ProgressSink; + +use super::combine_win::ItemRef; +use super::*; + +/// Which dose object, in which dataset. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) struct DoseRef { + pub slot: usize, + pub idx: usize, +} + +/// One picked structure. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) struct StructRef { + pub slot: usize, + pub item: ItemRef, +} + +/// The window's state; it stays open across runs. +pub(super) struct DvhDialog { + pub structures: Vec, + pub doses: Vec, + /// Cumulative, or the differential histogram. + pub cumulative: bool, + /// Dose axis as a percentage of [`Self::reference_dose`]. + pub dose_relative: bool, + /// Volume axis as a percentage of each structure. + pub volume_relative: bool, + /// What 100 % means on the dose axis. + pub reference_dose: f64, + pub metrics: Vec, + /// The text of the "add a column" field. + pub new_metric: String, + pub constraints: Vec, + pub protocol_name: String, + pub show_constraints: bool, + /// The last computed curves, in the order the structures were picked. + pub curves: Vec, + pub status: Option, +} + +impl DvhDialog { + fn new() -> DvhDialog { + DvhDialog { + structures: Vec::new(), + doses: Vec::new(), + cumulative: true, + dose_relative: false, + volume_relative: true, + reference_dose: 0.0, + metrics: dvh::default_metrics(), + new_metric: String::new(), + constraints: Vec::new(), + protocol_name: String::new(), + show_constraints: false, + curves: Vec::new(), + status: None, + } + } +} + +/// Everything a run needs, snapshotted when it starts. +struct DvhRequest { + /// Mask, lattice, name and colour of each structure. + items: Vec<(Vec, crate::volume::Grid, String, [u8; 3])>, + doses: Vec, +} + +/// What a finished run hands back. +pub struct DvhDone { + pub curves: Vec, + pub elapsed_secs: f64, +} + +impl ViewerApp { + /// Every dose object of both datasets, as (reference, label). + pub(super) fn dvh_dose_candidates(&self) -> Vec<(DoseRef, String)> { + let mut out = Vec::new(); + for (slot, name) in SLOT_NAMES.iter().enumerate() { + if slot == 1 && !self.comparison { + continue; + } + let Some(study) = self.slots[slot].study.as_ref() else { + continue; + }; + for (i, d) in study.doses.iter().enumerate() { + let label = if d.label.is_empty() { + format!("Dose {}", i + 1) + } else { + d.label.clone() + }; + out.push(( + DoseRef { slot, idx: i }, + if self.comparison { + format!("{name} · {label}") + } else { + label + }, + )); + } + } + out + } + + /// Every structure and segment of both datasets, as (reference, label). + pub(super) fn dvh_struct_candidates(&self) -> Vec<(StructRef, String)> { + let mut out = Vec::new(); + for (slot, name) in SLOT_NAMES.iter().enumerate() { + if slot == 1 && !self.comparison { + continue; + } + for (item, label) in self.combine_candidates(slot) { + out.push(( + StructRef { slot, item }, + if self.comparison { + format!("{name} · {label}") + } else { + label + }, + )); + } + } + out + } + + /// The prescription of the first plan that declares one — what the + /// percentage dose axis is measured against until the user says + /// otherwise. + fn prescription(&self) -> Option<(f64, String)> { + for slot in &self.slots { + let Some(study) = slot.study.as_ref() else { + continue; + }; + for p in &study.plans { + if let Some(d) = p.target_prescription_dose.filter(|d| *d > 0.0) { + let name = if p.label.is_empty() { + p.name.clone() + } else { + p.label.clone() + }; + return Some((d, name)); + } + } + } + None + } + + /// Tools ▶ DVH: open the window, seeded with everything already ticked + /// in the tree and the dose that is on display. + pub(super) fn open_dvh_dialog(&mut self, slot: usize, seed: Vec) { + if self.dvh_dialog.is_none() { + let mut d = DvhDialog::new(); + if let Some((dose, _)) = self.prescription() { + d.reference_dose = dose; + } + self.dvh_dialog = Some(d); + } + let doses = self.dvh_dose_candidates(); + let Some(d) = &mut self.dvh_dialog else { + return; + }; + for item in seed { + let r = StructRef { slot, item }; + if !d.structures.contains(&r) { + d.structures.push(r); + } + } + if d.doses.is_empty() { + // The dose the viewport is showing is the one meant, so start + // there rather than with an empty plot. + let active = DoseRef { + slot, + idx: self.slots[slot].active_dose, + }; + if doses.iter().any(|(r, _)| *r == active) { + d.doses.push(active); + } else if let Some((r, _)) = doses.first() { + d.doses.push(*r); + } + } + self.dvh_open = true; + self.start_dvh(); + } + + /// Snapshot the picks and compute on a worker thread. + pub(super) fn start_dvh(&mut self) { + if self.dvh_job.is_some() { + return; + } + let Some(d) = &self.dvh_dialog else { + return; + }; + if d.structures.is_empty() || d.doses.is_empty() { + if let Some(d) = &mut self.dvh_dialog { + d.curves.clear(); + } + return; + } + let mut items = Vec::with_capacity(d.structures.len()); + for s in &d.structures { + match self.item_mask_grid(s.slot, s.item) { + Some(v) => items.push(v), + None => { + self.error = Some( + "One of the picked structures is empty on its image series, so \ + its histogram would be meaningless. Remove it from the list." + .into(), + ); + return; + } + } + } + let mut doses = Vec::with_capacity(d.doses.len()); + for r in &d.doses { + let Some(g) = self.slots[r.slot] + .study + .as_ref() + .and_then(|s| s.doses.get(r.idx)) + else { + continue; + }; + doses.push(g.clone()); + } + if doses.is_empty() { + return; + } + let req = DvhRequest { items, doses }; + let progress = Arc::new(Progress::default()); + progress.set("Sampling dose…"); + self.dvh_job = Some(Job::spawn(progress, move |p| { + let t0 = std::time::Instant::now(); + let total = (req.items.len() * req.doses.len()).max(1); + let mut curves = Vec::with_capacity(total); + let mut n = 0usize; + for dose in &req.doses { + for (mask, grid, name, color) in &req.items { + if p.cancelled() { + break; + } + p.report( + n as f32 / total as f32, + &format!("{name} on {}", dose.label), + ); + if let Ok(c) = + dvh::compute(name, *color, mask, grid, dose, DvhParams::default()) + { + curves.push(c); + } + n += 1; + } + } + p.report(1.0, "Done"); + Ok(DvhDone { + curves, + elapsed_secs: t0.elapsed().as_secs_f64(), + }) + })); + } + + pub(super) fn on_dvh_done(&mut self, done: DvhDone) { + let Some(d) = &mut self.dvh_dialog else { + return; + }; + let truncated = done + .curves + .iter() + .filter(|c| c.outside_fraction() > 0.001) + .count(); + d.status = Some(format!( + "{} curve(s) in {:.2} s{}", + done.curves.len(), + done.elapsed_secs, + match truncated { + 0 => String::new(), + n => format!(" — {n} extend outside the dose grid"), + } + )); + d.curves = done.curves; + } + + /// The window. + pub(super) fn dvh_window(&mut self, ctx: &egui::Context) { + if !self.dvh_open || self.dvh_dialog.is_none() { + return; + } + // Everything that reads the whole of `self` is settled first. + let dose_list = self.dvh_dose_candidates(); + let struct_list = self.dvh_struct_candidates(); + let running = self.dvh_job.is_some(); + let progress = self.dvh_job.as_ref().map(|j| j.progress.clone()); + let prescription = self.prescription(); + + let mut open = self.dvh_open; + let mut recompute = false; + let mut cancel = false; + let mut export: Option = None; // Some(true) = curves, false = metrics + let mut load_protocol = false; + let mut save_protocol = false; + let d = self.dvh_dialog.as_mut().expect("checked above"); + + detach::tool_window( + ctx, + "dvh", + "📊 Dose–volume histograms", + &mut open, + detach::WinOpts::size(880.0, 620.0), + |ui| { + // ---- pickers ------------------------------------------ + ui.horizontal_wrapped(|ui| { + ui.label("Dose:"); + for (r, label) in &dose_list { + let on = d.doses.contains(r); + if ui.selectable_label(on, label).clicked() { + if on { + d.doses.retain(|x| x != r); + } else { + d.doses.push(*r); + } + recompute = true; + } + } + if dose_list.is_empty() { + ui.label( + egui::RichText::new("no RTDOSE loaded").color(warn_color(ui.visuals())), + ); + } + }); + ui.horizontal_wrapped(|ui| { + ui.label("Structures:"); + egui::ComboBox::from_id_salt("dvh_add_struct") + .selected_text("add…") + .width(200.0) + .show_ui(ui, |ui| { + for (r, label) in &struct_list { + if d.structures.contains(r) { + continue; + } + if ui.selectable_label(false, label).clicked() { + d.structures.push(*r); + recompute = true; + } + } + }); + let mut drop = None; + for (i, s) in d.structures.iter().enumerate() { + let label = struct_list + .iter() + .find(|(r, _)| r == s) + .map(|(_, l)| l.clone()) + .unwrap_or_else(|| "(gone)".into()); + if ui + .selectable_label(true, format!("{label} ✕")) + .on_hover_text("Remove from the plot") + .clicked() + { + drop = Some(i); + } + } + if let Some(i) = drop { + d.structures.remove(i); + recompute = true; + } + if !d.structures.is_empty() && ui.small_button("Clear").clicked() { + d.structures.clear(); + recompute = true; + } + }); + + // ---- axes --------------------------------------------- + ui.horizontal_wrapped(|ui| { + ui.selectable_value(&mut d.cumulative, true, "Cumulative") + .on_hover_text("Volume receiving at least each dose — the usual view"); + ui.selectable_value(&mut d.cumulative, false, "Differential") + .on_hover_text("Volume in each dose bin — where the cold spots are"); + ui.separator(); + ui.label("Dose:"); + ui.selectable_value(&mut d.dose_relative, false, "Gy"); + let has_ref = d.reference_dose > 0.0; + let rel = ui + .add_enabled_ui(has_ref, |ui| ui.selectable_label(d.dose_relative, "% of")) + .inner; + rel.clone().on_hover_text(if has_ref { + "Per cent of the reference dose" + } else { + "No plan in this study declares a prescription — type one" + }); + if rel.clicked() { + d.dose_relative = true; + } + ui.add( + egui::DragValue::new(&mut d.reference_dose) + .range(0.0..=1000.0) + .speed(0.1) + .suffix(" Gy"), + ); + if let Some((dose, from)) = &prescription { + if (d.reference_dose - *dose).abs() > 1e-6 + && ui + .small_button("↺") + .on_hover_text(format!("Back to the prescription of '{from}'")) + .clicked() + { + d.reference_dose = *dose; + } + } + ui.separator(); + ui.label("Volume:"); + ui.selectable_value(&mut d.volume_relative, true, "%"); + ui.selectable_value(&mut d.volume_relative, false, "cm³"); + }); + ui.separator(); + + // ---- the plot ----------------------------------------- + match &progress { + Some(p) => { + cancel = progress_row(ui, p); + } + None => { + let height = (ui.available_height() * 0.55).clamp(200.0, 520.0); + plot(ui, d, height); + } + } + + // ---- the table ---------------------------------------- + ui.separator(); + ui.horizontal_wrapped(|ui| { + ui.label("Columns:"); + let mut drop = None; + for (i, m) in d.metrics.iter().enumerate() { + if ui + .selectable_label(true, format!("{} ✕", m.label())) + .clicked() + { + drop = Some(i); + } + } + if let Some(i) = drop { + d.metrics.remove(i); + } + let resp = ui.add( + egui::TextEdit::singleline(&mut d.new_metric) + .hint_text("D98%, V20Gy, D2cc…") + .desired_width(110.0), + ); + let add = ui.small_button("+").clicked() + || (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + if add { + match Metric::parse(&d.new_metric) { + Some(m) => { + if !d.metrics.contains(&m) { + d.metrics.push(m); + } + d.new_metric.clear(); + } + None if !d.new_metric.trim().is_empty() => { + d.status = Some(format!( + "'{}' is not a metric — try D95%, D2cc, V20Gy or Dmean.", + d.new_metric.trim() + )); + } + None => {} + } + } + }); + metrics_table(ui, d); + + // ---- constraints -------------------------------------- + ui.separator(); + let header = if d.constraints.is_empty() { + "Constraints".to_string() + } else { + let v = dvh::check(&d.constraints, &d.curves); + let failed = v.iter().filter(|x| !x.pass).count(); + format!("Constraints — {} of {} met", v.len() - failed, v.len()) + }; + egui::CollapsingHeader::new(header) + .default_open(d.show_constraints) + .show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button("Load protocol…").clicked() { + load_protocol = true; + } + if ui + .add_enabled( + !d.constraints.is_empty(), + egui::Button::new("Save protocol…"), + ) + .clicked() + { + save_protocol = true; + } + if !d.protocol_name.is_empty() { + ui.weak(&d.protocol_name); + } + }); + if d.constraints.is_empty() { + ui.weak( + "A protocol is a text file, one constraint per line: \ + Cord Dmax <= 45 · PTV* D95% >= 57 · \ + \"Parotid L\" Dmean <= 26", + ); + } else { + constraint_table(ui, d); + } + }); + + // ---- footer ------------------------------------------- + ui.separator(); + ui.horizontal(|ui| { + if ui + .add_enabled(!d.curves.is_empty(), egui::Button::new("Export curves…")) + .clicked() + { + export = Some(true); + } + if ui + .add_enabled(!d.curves.is_empty(), egui::Button::new("Export table…")) + .clicked() + { + export = Some(false); + } + if ui.button("Recompute").clicked() { + recompute = true; + } + if let Some(s) = &d.status { + ui.weak(s); + } + }); + let truncated: Vec<&Dvh> = d + .curves + .iter() + .filter(|c| c.outside_fraction() > 0.001) + .collect(); + if !truncated.is_empty() { + let names: Vec = truncated + .iter() + .map(|c| format!("{} ({:.0} %)", c.name, c.outside_fraction() * 100.0)) + .collect(); + ui.label( + egui::RichText::new(format!( + "⚠ Outside the dose grid, counted at zero dose: {}", + names.join(", ") + )) + .small() + .color(warn_color(ui.visuals())), + ); + } + }, + ); + + self.dvh_open = open; + if cancel { + if let Some(j) = &self.dvh_job { + j.progress.cancel(); + } + } + if load_protocol { + if let Some(path) = rfd::FileDialog::new() + .set_title("Open a constraint protocol") + .add_filter("protocol", &["txt", "csv", "protocol"]) + .pick_file() + { + match std::fs::read_to_string(&path) { + Ok(text) => { + let cs = dvh::parse_protocol(&text); + if let Some(d) = &mut self.dvh_dialog { + if cs.is_empty() { + self.error = Some( + "No constraints were recognised in that file. Each line \ + is STRUCTURE METRIC <= LIMIT, for example \ + 'Cord Dmax <= 45'." + .into(), + ); + } else { + d.constraints = cs; + d.protocol_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + d.show_constraints = true; + } + } + } + Err(e) => self.error = Some(format!("Could not read the protocol: {e}")), + } + } + } + if save_protocol { + let text = self + .dvh_dialog + .as_ref() + .map(|d| dvh::write_protocol(&d.constraints)) + .unwrap_or_default(); + if let Some(path) = rfd::FileDialog::new() + .set_title("Save the protocol") + .set_file_name("protocol.txt") + .save_file() + { + if let Err(e) = std::fs::write(&path, text) { + self.error = Some(format!("Could not write the protocol: {e}")); + } + } + } + if let Some(curves) = export { + let text = self.dvh_dialog.as_ref().map(|d| { + if curves { + dvh::curves_csv(&d.curves, d.volume_relative) + } else { + dvh::metrics_csv(&d.curves, &d.metrics) + } + }); + if let (Some(text), Some(path)) = ( + text, + rfd::FileDialog::new() + .set_title(if curves { + "Save the DVH curves" + } else { + "Save the metrics table" + }) + .set_file_name(if curves { "dvh.csv" } else { "dvh_metrics.csv" }) + .save_file(), + ) { + match std::fs::write(&path, text) { + Ok(()) => self.notice = Some(format!("Written to {}", path.display())), + Err(e) => self.error = Some(format!("Could not write the file: {e}")), + } + } + } + if recompute && !running { + self.start_dvh(); + } + } +} + +/// The metrics table: one row per curve. +fn metrics_table(ui: &mut egui::Ui, d: &DvhDialog) { + if d.curves.is_empty() { + ui.weak("Pick a dose object and one or more structures."); + return; + } + let units = dvh::nice_units(&d.curves[0].units); + let several_doses = d.doses.len() > 1; + egui::ScrollArea::horizontal() + .id_salt("dvh_table") + .max_height(180.0) + .show(ui, |ui| { + egui::Grid::new("dvh_metrics") + .striped(true) + .num_columns(d.metrics.len() + 2) + .show(ui, |ui| { + ui.label(egui::RichText::new("Structure").strong()); + if several_doses { + ui.label(egui::RichText::new("Dose").strong()); + } + for m in &d.metrics { + ui.label( + egui::RichText::new(format!( + "{} [{}]", + m.label(), + display_unit(m, &units, d) + )) + .strong(), + ); + } + ui.end_row(); + for c in &d.curves { + let col = egui::Color32::from_rgb(c.color[0], c.color[1], c.color[2]); + ui.horizontal(|ui| { + let (rect, _) = ui + .allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 2.0, col); + ui.label(&c.name); + }); + if several_doses { + ui.label(&c.dose_label); + } + for m in &d.metrics { + let v = m.evaluate(c); + let v = if m.is_dose() && d.dose_relative && d.reference_dose > 0.0 { + v / d.reference_dose * 100.0 + } else { + v + }; + ui.label(format!("{v:.2}")); + } + ui.end_row(); + } + }); + }); +} + +fn display_unit(m: &Metric, units: &str, d: &DvhDialog) -> String { + if m.is_dose() && d.dose_relative && d.reference_dose > 0.0 { + "%".into() + } else { + m.unit(units) + } +} + +/// The constraint table, with the pass/fail column. +fn constraint_table(ui: &mut egui::Ui, d: &DvhDialog) { + let verdicts = dvh::check(&d.constraints, &d.curves); + egui::Grid::new("dvh_constraints") + .striped(true) + .num_columns(5) + .show(ui, |ui| { + for h in ["", "Structure", "Metric", "Limit", "Value"] { + ui.label(egui::RichText::new(h).strong()); + } + ui.end_row(); + for v in &verdicts { + let (mark, color) = match (v.value.is_some(), v.pass) { + (false, _) => ("—", warn_color(ui.visuals())), + (true, true) => ("✔", egui::Color32::from_rgb(60, 160, 80)), + (true, false) => ("✖", egui::Color32::from_rgb(200, 70, 70)), + }; + ui.label(egui::RichText::new(mark).color(color).strong()); + ui.label(if v.structure.is_empty() { + format!("{} (not found)", v.constraint.structure) + } else { + v.structure.clone() + }); + ui.label(v.constraint.metric.label()); + ui.label(format!( + "{} {:.2}", + v.constraint.cmp.symbol(), + v.constraint.limit + )); + match v.value { + Some(x) => ui.label(egui::RichText::new(format!("{x:.2}")).color(color)), + None => ui.label("—"), + }; + ui.end_row(); + } + }); +} + +/// Axis limits and the mapping onto the panel. +struct Axes { + x_max: f64, + y_max: f64, + rect: egui::Rect, +} + +impl Axes { + fn at(&self, x: f64, y: f64) -> egui::Pos2 { + let fx = (x / self.x_max).clamp(0.0, 1.0) as f32; + let fy = (y / self.y_max).clamp(0.0, 1.0) as f32; + egui::pos2( + self.rect.left() + fx * self.rect.width(), + self.rect.bottom() - fy * self.rect.height(), + ) + } + fn dose_at(&self, px: f32) -> f64 { + ((px - self.rect.left()) / self.rect.width().max(1.0)) as f64 * self.x_max + } +} + +/// "Nice" tick step: 1, 2 or 5 times a power of ten. +fn tick_step(span: f64, target: usize) -> f64 { + if span <= 0.0 { + return 1.0; + } + let raw = span / target.max(1) as f64; + let mag = 10f64.powf(raw.log10().floor()); + let n = raw / mag; + mag * if n < 1.5 { + 1.0 + } else if n < 3.5 { + 2.0 + } else if n < 7.5 { + 5.0 + } else { + 10.0 + } +} + +/// The plot itself: axes, gridlines, curves, legend and a hover readout. +fn plot(ui: &mut egui::Ui, d: &DvhDialog, height: f32) { + let width = ui.available_width(); + let (outer, response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter_at(outer); + let vis = ui.visuals(); + painter.rect_filled(outer, 2.0, vis.extreme_bg_color); + if d.curves.is_empty() { + painter.text( + outer.center(), + egui::Align2::CENTER_CENTER, + "Pick a dose object and one or more structures.", + egui::FontId::proportional(13.0), + vis.weak_text_color(), + ); + return; + } + + let scale = if d.dose_relative && d.reference_dose > 0.0 { + 100.0 / d.reference_dose + } else { + 1.0 + }; + let units = if d.dose_relative && d.reference_dose > 0.0 { + "%".to_string() + } else { + dvh::nice_units(&d.curves[0].units) + }; + // Room for the axis labels; the legend sits inside the panel. + let rect = egui::Rect::from_min_max( + outer.min + egui::vec2(52.0, 10.0), + outer.max - egui::vec2(12.0, 30.0), + ); + let x_max = d + .curves + .iter() + .map(|c| c.dose_extent() * scale) + .fold(0.0, f64::max) + .max(1e-6) + * 1.02; + let y_max = if d.cumulative { + if d.volume_relative { + 100.0 + } else { + d.curves.iter().map(|c| c.volume_cm3).fold(0.0, f64::max) * 1.05 + } + } else { + // Differential: each curve's tallest bin, normalised the same way + // the curve itself will be drawn. + d.curves + .iter() + .map(|c| { + let peak = c.bins.iter().cloned().fold(0.0, f64::max); + if d.volume_relative && c.volume_cm3 > 0.0 { + peak / c.volume_cm3 * 100.0 + } else { + peak + } + }) + .fold(0.0, f64::max) + * 1.1 + } + .max(1e-9); + let ax = Axes { x_max, y_max, rect }; + + // ---- grid and ticks ---- + let grid_stroke = egui::Stroke::new(1.0, vis.weak_text_color().gamma_multiply(0.25)); + let axis_stroke = egui::Stroke::new(1.0, vis.text_color().gamma_multiply(0.6)); + let font = egui::FontId::proportional(10.0); + let xs = tick_step(x_max, 8); + let mut t = 0.0; + while t <= x_max + 1e-9 { + let p = ax.at(t, 0.0); + painter.line_segment( + [egui::pos2(p.x, rect.top()), egui::pos2(p.x, rect.bottom())], + grid_stroke, + ); + painter.text( + egui::pos2(p.x, rect.bottom() + 3.0), + egui::Align2::CENTER_TOP, + format!("{t:.0}"), + font.clone(), + vis.text_color(), + ); + t += xs; + } + let ys = tick_step(y_max, 6); + let mut t = 0.0; + while t <= y_max + 1e-9 { + let p = ax.at(0.0, t); + painter.line_segment( + [egui::pos2(rect.left(), p.y), egui::pos2(rect.right(), p.y)], + grid_stroke, + ); + painter.text( + egui::pos2(rect.left() - 5.0, p.y), + egui::Align2::RIGHT_CENTER, + if ys < 1.0 { + format!("{t:.2}") + } else { + format!("{t:.0}") + }, + font.clone(), + vis.text_color(), + ); + t += ys; + } + painter.rect_stroke(rect, 0.0, axis_stroke, egui::StrokeKind::Inside); + painter.text( + egui::pos2(rect.center().x, outer.bottom() - 2.0), + egui::Align2::CENTER_BOTTOM, + format!("Dose [{units}]"), + font.clone(), + vis.text_color(), + ); + painter.text( + egui::pos2(outer.left() + 2.0, rect.top()), + egui::Align2::LEFT_TOP, + if d.volume_relative { + "Volume [%]".to_string() + } else { + "Volume [cm³]".to_string() + }, + font.clone(), + vis.text_color(), + ); + + // ---- the curves ---- + // Structures keep their colour; the dose object picks the line style, so + // two plans over the same structures read as one colour in two dashes. + let dose_order: Vec = { + let mut v: Vec = Vec::new(); + for c in &d.curves { + if !v.contains(&c.dose_label) { + v.push(c.dose_label.clone()); + } + } + v + }; + for c in &d.curves { + let col = egui::Color32::from_rgb(c.color[0], c.color[1], c.color[2]); + let stroke = egui::Stroke::new(1.6, col); + let pts: Vec = if d.cumulative { + c.cumulative() + .into_iter() + .map(|(dose, vol)| { + let y = if d.volume_relative { + if c.volume_cm3 > 0.0 { + vol / c.volume_cm3 * 100.0 + } else { + 0.0 + } + } else { + vol + }; + ax.at(dose * scale, y) + }) + .collect() + } else { + c.differential() + .into_iter() + .map(|(dose, vol)| { + let y = if d.volume_relative && c.volume_cm3 > 0.0 { + vol / c.volume_cm3 * 100.0 + } else { + vol + }; + ax.at(dose * scale, y) + }) + .collect() + }; + let style = dose_order + .iter() + .position(|l| *l == c.dose_label) + .unwrap_or(0); + match style { + 0 => painter.add(egui::Shape::line(pts, stroke)), + 1 => painter.add(egui::Shape::Vec(egui::Shape::dashed_line( + &pts, stroke, 6.0, 4.0, + ))), + _ => painter.add(egui::Shape::Vec(egui::Shape::dashed_line( + &pts, stroke, 2.0, 3.0, + ))), + }; + } + + // ---- legend ---- + let mut y = rect.top() + 4.0; + for c in &d.curves { + let col = egui::Color32::from_rgb(c.color[0], c.color[1], c.color[2]); + let sw = egui::Rect::from_min_size( + egui::pos2(rect.right() - 150.0, y + 3.0), + egui::vec2(14.0, 3.0), + ); + painter.rect_filled(sw, 0.0, col); + let label = if dose_order.len() > 1 { + format!("{} · {}", c.name, c.dose_label) + } else { + c.name.clone() + }; + painter.text( + egui::pos2(rect.right() - 132.0, y), + egui::Align2::LEFT_TOP, + label, + font.clone(), + vis.text_color(), + ); + y += 13.0; + if y > rect.bottom() - 12.0 { + break; + } + } + + // ---- hover readout ---- + if let Some(pos) = response.hover_pos() { + if rect.contains(pos) { + let dose = ax.dose_at(pos.x); + painter.line_segment( + [ + egui::pos2(pos.x, rect.top()), + egui::pos2(pos.x, rect.bottom()), + ], + egui::Stroke::new(1.0, vis.text_color().gamma_multiply(0.5)), + ); + let real = dose / scale; + let mut lines = vec![format!("{dose:.1} {units}")]; + for c in &d.curves { + let v = if d.volume_relative { + format!("{:.1} %", c.volume_fraction_at_dose(real) * 100.0) + } else { + format!("{:.2} cm³", c.volume_at_dose(real)) + }; + lines.push(format!("{}: {v}", c.name)); + } + response.clone().on_hover_text(lines.join("\n")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tick_steps_are_readable_numbers() { + assert_eq!(tick_step(70.0, 7), 10.0); + assert_eq!(tick_step(100.0, 5), 20.0); + assert_eq!(tick_step(1.0, 5), 0.2); + assert_eq!(tick_step(0.0, 5), 1.0); + } + + #[test] + fn the_axes_map_the_corners_onto_the_panel() { + let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 50.0)); + let ax = Axes { + x_max: 10.0, + y_max: 100.0, + rect, + }; + // Volume 100 % at dose 0 is the top left; dose 10 at 0 % the bottom right. + assert_eq!(ax.at(0.0, 100.0), egui::pos2(0.0, 0.0)); + assert_eq!(ax.at(10.0, 0.0), egui::pos2(100.0, 50.0)); + // …and reading a position back gives the dose again. + assert!((ax.dose_at(50.0) - 5.0).abs() < 1e-6); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index d6b4b95..00392d9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ use crate::autoseg; use crate::bodymask; use crate::dicom_export; use crate::extras; +use crate::fourd; use crate::gen_test_data::{self, GenParams}; use crate::geometry::Vec3; use crate::loader::{self, LoadedStudy}; @@ -36,11 +37,18 @@ use crate::volume::{ViewPlane, Volume}; mod body_win; mod box_seg; mod chrome; +mod combine_win; +mod compare_win; mod d3; +mod detach; mod dialogs; mod drr_win; +mod dvh_win; mod jobs; mod models_win; +mod motion_results; +mod motion_win; +mod pacs_win; mod panels; mod planar; mod prompt_seg; @@ -51,10 +59,12 @@ mod seg; mod seg_engines; mod sets; mod theme; +mod transfer_win; mod tree; mod views; use drr_win::DrrDialog; +use pacs_win::{PacsOutcome, PacsWindow}; use propagate_win::{PropOutcome, PropagateDialog}; use reg_panel::{RegOutcome, RegRoi}; use rename::{RenameDialog, RenameTarget}; @@ -63,6 +73,46 @@ use theme::*; const SLOT_NAMES: [&str; 2] = ["A", "B"]; +/// A 4D-group edit requested from the data tree's context menus, applied +/// after the frame's borrows are released (the tree renders behind a shared +/// borrow of the study). +enum FourDAction { + /// Add a series to an existing group, as a phase. + Add { + slot: usize, + group: usize, + series: usize, + }, + /// Start a new custom group from one series. + New { slot: usize, series: usize }, + /// Remove one member from a group. + RemoveMember { + slot: usize, + group: usize, + member: usize, + }, + /// Move a member one place up (−1) or down (+1). + Shift { + slot: usize, + group: usize, + member: usize, + delta: isize, + }, + /// Cycle a member's role (phase ▸ AVG ▸ MIP ▸ MinIP). + SetRole { + slot: usize, + group: usize, + member: usize, + role: fourd::Role, + }, + /// Dissolve the whole group (the series stay). + Dissolve { slot: usize, group: usize }, + /// Re-run automatic detection, keeping custom groups. + Redetect { slot: usize }, + /// Open the 4D motion tool on this group. + Analyse { slot: usize, group: usize }, +} + /// The auto-segmentation window: its parameters, and the run they start. struct AutosegDialog { slot: usize, @@ -377,18 +427,17 @@ enum LoadResult { /// A unit of work running on a background thread: a shared progress handle /// plus the channel its result arrives on. Every background feature in the /// app has this shape, and [`poll_job`] drives them all identically. -struct Job { - progress: Arc

, +struct Job { + progress: Arc, rx: mpsc::Receiver, } -impl Job { +impl Job { /// Run `work` on a new thread and return the handle to poll for its /// result. The worker gets the progress handle; the caller keeps a clone. - fn spawn(progress: Arc

, work: impl FnOnce(&P) -> T + Send + 'static) -> Job + fn spawn(progress: Arc, work: impl FnOnce(&Progress) -> T + Send + 'static) -> Job where T: Send + 'static, - P: Send + Sync + 'static, { let (tx, rx) = mpsc::channel(); let p = progress.clone(); @@ -402,8 +451,8 @@ impl Job { /// Poll a background job. Returns its result once, clearing the slot; reports /// a worker that died without answering into `error`; otherwise schedules the /// next poll and returns `None`. -fn poll_job( - slot: &mut Option>, +fn poll_job( + slot: &mut Option>, ctx: &egui::Context, what: &str, error: &mut Option, @@ -429,8 +478,8 @@ fn poll_job( /// [`poll_job`] for the jobs that answer with `(slot, Result)`: a failure is /// reported as `"{what} failed: …"`, except a cancellation, which is what /// the user asked for and needs no dialog. -fn poll_tool_job( - slot: &mut Option), P>>, +fn poll_tool_job( + slot: &mut Option)>>, ctx: &egui::Context, what: &str, error: &mut Option, @@ -557,7 +606,7 @@ struct TreeAction { /// series, both hold named, coloured items — even though one stores contours /// and the other voxel masks. Conversions between the two happen on /// transfer (`ViewerApp::apply_item_action`). -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] enum SetKind { /// RT Structure Set: contours. Structures, @@ -636,6 +685,16 @@ enum ItemAction { from: SetRef, idx: usize, }, + /// Open the structure-algebra window with these items as its operands. + Combine { + from: SetRef, + items: Vec, + }, + /// Plot these items' dose–volume histograms. + Dvh { + from: SetRef, + items: Vec, + }, /// Write these segments as a DICOM SEG file of their own. ExportSeg { from: SetRef, @@ -851,6 +910,15 @@ pub struct ViewerApp { /// engines (persisted in the settings file; blank = the default). models_dir: String, + /// Root of the local patient archive (persisted; blank = the default). + archive_dir: String, + + // Tools ▶ PACS: the patient archive window. + /// The window, when open. + pacs: Option, + /// The archive job in flight — a scan, an import, an upload or a removal. + pacs_job: Option>>, + // Tools ▶ Downloaded models: the inventory window. models_open: bool, /// The inventory with each model's state, re-read at most twice a second. @@ -878,6 +946,38 @@ pub struct ViewerApp { /// The tool window, when open; it stays open across runs. body_dialog: Option, + // Dose–volume histograms (see `dvh`), in a window of their own. + dvh_open: bool, + dvh_dialog: Option, + dvh_job: Option>>, + + // Structure algebra (see `structops`): combining contours and segments. + combine_job: Option>, + combine_slot: usize, + combine_dialog: Option, + + // 4D motion / ITV analysis (see `motion` and `fourd`). + motion_job: Option>, + motion_slot: usize, + motion_dialog: Option, + /// The last run's settings, re-applicable to another dataset / study. + motion_recipe: Option, + /// Every finished run of this session, newest last. + motion_reports: Vec, + /// The results window: visibility, selected run, comparison run. + motion_results_open: bool, + motion_sel: usize, + motion_cmp: Option, + + // Tools ▶ Transfer by relationship. + transfer_dialog: Option, + + // Tools ▶ Compare structures. + compare_dialog: Option, + + /// Deferred 4D-group edit from the data tree's context menus. + fourd_action: Option, + // Prompt-driven segmentation (SegVol re-implementation, see `segvol`). segvol_job: Option>, segvol_slot: usize, @@ -911,6 +1011,10 @@ pub struct ViewerApp { /// The side panel is expanded (View ▶ Left panel, F9, or the arrow on /// the panel edge). Collapsed, the views have the whole window. side_open: bool, + /// Tool windows currently living in their own window of the operating + /// system. The live set is egui memory (`detach`); this is the copy last + /// written to the settings file, so a change can be spotted per frame. + detached_windows: std::collections::BTreeSet, /// Light / dark / follow-the-system appearance, persisted between runs. theme: egui::ThemePreference, @@ -937,11 +1041,22 @@ impl ViewerApp { ) -> Self { let prefs = settings::load(); cc.egui_ctx.set_theme(prefs.theme); + // The windows the user last pulled out open in their own window + // again — `detach` reads the set straight from egui memory. + detach::set_detached_ids( + &cc.egui_ctx, + prefs.detached_windows.iter().cloned().collect(), + ); let models_dir = prefs .models_dir .as_ref() .map(|p| p.display().to_string()) .unwrap_or_default(); + let archive_dir = prefs + .archive_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); // Installations that predate the single `models/` root keep their // downloads; the folders are moved into place, never re-fetched. let moved = models::migrate_legacy_layout(&models::root_from_setting(&models_dir)); @@ -1033,6 +1148,9 @@ impl ViewerApp { show_labels: true, show_isocenters: true, models_dir, + archive_dir, + pacs: None, + pacs_job: None, models_open: false, models_scan: Vec::new(), models_scan_at: f64::NEG_INFINITY, @@ -1046,6 +1164,25 @@ impl ViewerApp { body_slot: 0, body_dialog: None, + dvh_open: false, + dvh_dialog: None, + dvh_job: None, + + combine_job: None, + combine_slot: 0, + combine_dialog: None, + motion_job: None, + motion_slot: 0, + motion_dialog: None, + motion_recipe: None, + motion_reports: Vec::new(), + motion_results_open: false, + motion_sel: 0, + motion_cmp: None, + transfer_dialog: None, + compare_dialog: None, + fourd_action: None, + segvol_job: None, segvol_slot: 0, segvol_dialog: None, @@ -1070,6 +1207,7 @@ impl ViewerApp { module_registration: prefs.module_registration, module_simulation: prefs.module_simulation, side_open: true, + detached_windows: prefs.detached_windows.iter().cloned().collect(), theme: prefs.theme, settings_error: None, }; @@ -1098,11 +1236,20 @@ impl ViewerApp { } else { Some(PathBuf::from(self.models_dir.trim())) }; + let default_archive = crate::archive::default_root().display().to_string(); + let archive_dir = + if self.archive_dir.trim().is_empty() || self.archive_dir.trim() == default_archive { + None + } else { + Some(PathBuf::from(self.archive_dir.trim())) + }; match settings::save(&Settings { theme: self.theme, models_dir, + archive_dir, module_registration: self.module_registration, module_simulation: self.module_simulation, + detached_windows: self.detached_windows.iter().cloned().collect(), }) { Ok(()) => self.settings_error = None, Err(e) => { @@ -1227,7 +1374,7 @@ impl eframe::App for ViewerApp { // Poll a model download / update batch. self.poll_models_job(&ctx); - // Poll the three segmentation engines. + // Poll the tool windows' workers. if let Some((slot, result)) = poll_tool_job(&mut self.autoseg_job, &ctx, AUTOSEG.name, &mut self.error) { @@ -1251,6 +1398,23 @@ impl eframe::App for ViewerApp { { self.on_body_done(slot, result); } + if let Some((slot, result)) = + poll_tool_job(&mut self.combine_job, &ctx, COMBINE.name, &mut self.error) + { + self.on_combine_done(slot, result); + } + match poll_job(&mut self.dvh_job, &ctx, "DVH", &mut self.error) { + Some(Ok(done)) => self.on_dvh_done(done), + Some(Err(e)) if !progress::is_cancellation(&e) => { + self.error = Some(format!("DVH failed: {e:#}")); + } + _ => {} + } + if let Some((slot, outcome)) = + poll_tool_job(&mut self.motion_job, &ctx, MOTION.name, &mut self.error) + { + self.on_motion_done(slot, outcome); + } // Poll background registration. if let Some((fixed_slot, out)) = @@ -1269,6 +1433,13 @@ impl eframe::App for ViewerApp { self.set_cursor(fixed_slot, cursor, usize::MAX); } + // Poll an archive job — a scan, an import, an upload or a removal. + match poll_job(&mut self.pacs_job, &ctx, "Archive", &mut self.error) { + Some(Ok(outcome)) => self.on_pacs_done(outcome), + Some(Err(e)) => self.error = Some(format!("Archive: {e:#}")), + None => {} + } + // Poll a DRR rendering. match poll_job(&mut self.drr_job, &ctx, "DRR", &mut self.error) { Some(Ok(images)) => self.on_drr_done(images), @@ -1345,9 +1516,19 @@ impl eframe::App for ViewerApp { if let Some(action) = self.item_action.take() { self.apply_item_action(action); } + if let Some(action) = self.fourd_action.take() { + self.apply_fourd_action(action); + } if let Some(target) = self.rename_request.take() { self.open_rename(target); } self.modals(&ctx); + // A window was pulled out of the main window or pushed back into it: + // remember which, so it opens the same way next time the viewer runs. + let detached = detach::detached_ids(&ctx); + if detached != self.detached_windows { + self.detached_windows = detached; + self.persist_settings(); + } } } diff --git a/src/app/models_win.rs b/src/app/models_win.rs index 65846dc..1993c20 100644 --- a/src/app/models_win.rs +++ b/src/app/models_win.rs @@ -14,7 +14,7 @@ use anyhow::Context; use super::*; -use crate::models::{self, AssetStatus, ModelAsset}; +use crate::models::{AssetStatus, ModelAsset}; /// A row's (or the header's) deferred button press. /// @@ -140,13 +140,13 @@ impl ViewerApp { .sum(); let spare: u64 = scan.iter().map(|(_, s)| s.spare_bytes).sum(); - egui::Window::new("📦 Downloaded models") - .id(egui::Id::new("models_window")) - .collapsible(true) - .resizable(true) - .default_width(560.0) - .open(&mut open) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "models", + "📦 Downloaded models", + &mut open, + detach::WinOpts::width(560.0), + |ui| { ui.label( "Every model the segmentation tools can fetch. Weights are \ downloaded once, converted to a cache beside them, and never touched \ @@ -255,7 +255,8 @@ impl ViewerApp { } ui.weak(RESEARCH_NOTE); }); - }); + }, + ); self.models_scan = scan; diff --git a/src/app/motion_results.rs b/src/app/motion_results.rs new file mode 100644 index 0000000..e767130 --- /dev/null +++ b/src/app/motion_results.rs @@ -0,0 +1,523 @@ +//! The 4D motion results window: tables, charts, CSV export, and the +//! side-by-side comparison of two runs (e.g. upright vs. supine, or +//! dataset A vs. B). +//! +//! The charts are drawn with the egui painter directly — a displacement- +//! vs-phase line chart and grouped bar charts are simple enough that a +//! plotting dependency would cost more than it gives. + +use crate::motion::{self, MotionModel, MotionReport}; + +use super::*; + +/// A color per track that stays stable across the charts and tables. +fn track_color(i: usize) -> Color32 { + const C: [Color32; 8] = [ + Color32::from_rgb(0x4c, 0x8b, 0xf5), // blue + Color32::from_rgb(0x38, 0xa1, 0x69), // green + Color32::from_rgb(0xe2, 0x74, 0x3c), // orange + Color32::from_rgb(0xb1, 0x5b, 0xd6), // purple + Color32::from_rgb(0x2f, 0xa8, 0xa8), // teal + Color32::from_rgb(0xd6, 0x5b, 0x7a), // rose + Color32::from_rgb(0x8f, 0x9a, 0x2f), // olive + Color32::from_rgb(0x80, 0x80, 0x80), // gray + ]; + C[i % C.len()] +} + +/// The reference structure's curve gets the manuscript's dashed red. +const REF_COLOR: Color32 = Color32::from_rgb(0xd6, 0x45, 0x45); + +/// One line of a line chart: label, color, y per phase. +struct Series { + label: String, + color: Color32, + values: Vec, + dashed: bool, +} + +/// Displacement magnitude (or drift) per phase, one polyline per track. +fn line_chart(ui: &mut egui::Ui, phases: &[String], series: &[Series], y_label: &str) { + if series.is_empty() { + return; + } + let h = 160.0f32; + let (rect, _) = ui.allocate_exact_size( + Vec2::new(ui.available_width().max(260.0), h), + Sense::hover(), + ); + let painter = ui.painter_at(rect); + let axis_color = ui.visuals().weak_text_color(); + let font = FontId::proportional(10.0); + + let max_y = series + .iter() + .flat_map(|s| s.values.iter().copied()) + .fold(1.0f64, f64::max) + .ceil(); + let left = rect.left() + 34.0; + let bottom = rect.bottom() - 16.0; + let top = rect.top() + 6.0; + let right = rect.right() - 6.0; + let x_of = |i: usize| { + left + (right - left) + * if phases.len() > 1 { + i as f32 / (phases.len() - 1) as f32 + } else { + 0.5 + } + }; + let y_of = |v: f64| bottom - (bottom - top) * (v / max_y) as f32; + + // Axes, y ticks and gridlines. + painter.line_segment( + [Pos2::new(left, top), Pos2::new(left, bottom)], + Stroke::new(1.0, axis_color), + ); + painter.line_segment( + [Pos2::new(left, bottom), Pos2::new(right, bottom)], + Stroke::new(1.0, axis_color), + ); + let ticks = 4; + for t in 0..=ticks { + let v = max_y * t as f64 / ticks as f64; + let y = y_of(v); + if t > 0 { + painter.line_segment( + [Pos2::new(left, y), Pos2::new(right, y)], + Stroke::new(0.5, axis_color.linear_multiply(0.3)), + ); + } + painter.text( + Pos2::new(left - 4.0, y), + Align2::RIGHT_CENTER, + format!("{v:.0}"), + font.clone(), + axis_color, + ); + } + painter.text( + Pos2::new(left, top - 2.0), + Align2::LEFT_BOTTOM, + y_label, + font.clone(), + axis_color, + ); + // Phase labels, thinned when they would collide. + let step = (phases.len() / 10).max(1); + for (i, ph) in phases.iter().enumerate() { + if i % step != 0 && i != phases.len() - 1 { + continue; + } + painter.text( + Pos2::new(x_of(i), bottom + 2.0), + Align2::CENTER_TOP, + ph, + font.clone(), + axis_color, + ); + } + // The polylines. + for s in series { + for w in s.values.windows(2).enumerate() { + let (i, pair) = w; + if s.dashed && i % 2 == 1 { + continue; + } + painter.line_segment( + [ + Pos2::new(x_of(i), y_of(pair[0])), + Pos2::new(x_of(i + 1), y_of(pair[1])), + ], + Stroke::new(1.6, s.color), + ); + } + for (i, &v) in s.values.iter().enumerate() { + painter.circle_filled(Pos2::new(x_of(i), y_of(v)), 2.2, s.color); + } + } + // Legend. + ui.horizontal_wrapped(|ui| { + for s in series { + ui.colored_label(s.color, format!("■ {}", s.label)); + } + }); +} + +/// Grouped horizontal bars: one row per entry, value + label. +fn bar_rows(ui: &mut egui::Ui, entries: &[(String, f64, Color32)], unit: &str) { + let max = entries.iter().map(|e| e.1).fold(1e-9f64, f64::max); + for (label, v, color) in entries { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(120.0, 12.0), Sense::hover()); + let w = rect.width() * (*v / max) as f32; + ui.painter_at(rect).rect_filled( + Rect::from_min_size(rect.min, Vec2::new(w.max(1.0), rect.height())), + 2.0, + *color, + ); + ui.label(format!("{v:.2} {unit} {label}")); + }); + } +} + +impl ViewerApp { + pub(super) fn motion_results_window(&mut self, ctx: &egui::Context) { + if !self.motion_results_open { + return; + } + if self.motion_reports.is_empty() { + self.motion_results_open = false; + return; + } + let mut open = true; + let mut export: Option = None; + self.motion_sel = self.motion_sel.min(self.motion_reports.len() - 1); + if let Some(c) = self.motion_cmp { + if c >= self.motion_reports.len() || c == self.motion_sel { + self.motion_cmp = None; + } + } + let mut sel = self.motion_sel; + let mut cmp = self.motion_cmp; + { + let reports = &self.motion_reports; + detach::tool_window( + ctx, + "motion_results", + MOTION.titled("results", self.motion_slot.min(1)), + &mut open, + detach::WinOpts::width(560.0), + |ui| { + ui.horizontal(|ui| { + ui.label("Run:"); + egui::ComboBox::from_id_salt("motion_run") + .width(280.0) + .selected_text(reports[sel].run_name.clone()) + .show_ui(ui, |ui| { + for (i, r) in reports.iter().enumerate() { + ui.selectable_value(&mut sel, i, &r.run_name); + } + }); + ui.label("Compare with:"); + let cmp_text = cmp + .map(|i| reports[i].run_name.clone()) + .unwrap_or_else(|| "(none)".into()); + egui::ComboBox::from_id_salt("motion_cmp") + .width(220.0) + .selected_text(cmp_text) + .show_ui(ui, |ui| { + ui.selectable_value(&mut cmp, None, "(none)"); + for (i, r) in reports.iter().enumerate() { + if i != sel { + ui.selectable_value(&mut cmp, Some(i), &r.run_name); + } + } + }); + }); + ui.separator(); + egui::ScrollArea::vertical() + .max_height(480.0) + .show(ui, |ui| { + let r = &reports[sel]; + Self::report_body(ui, r, sel); + if let Some(ci) = cmp { + ui.separator(); + ui.strong(format!("Comparison — {}", reports[ci].run_name)); + Self::report_body(ui, &reports[ci], ci); + ui.separator(); + Self::comparison_body(ui, r, &reports[ci], (sel, ci)); + } + }); + ui.separator(); + ui.horizontal(|ui| { + if ui + .button("💾 Export CSV") + .on_hover_text("The selected run as one long-format CSV file") + .clicked() + { + export = Some(sel); + } + if let Some(ci) = cmp { + if ui.button("💾 Export comparison CSV").clicked() { + export = Some(usize::MAX - ci); + } + } + }); + }, + ); + } + self.motion_sel = sel; + self.motion_cmp = cmp; + if let Some(code) = export { + let (i, also) = if code > usize::MAX / 2 { + (self.motion_sel, Some(usize::MAX - code)) + } else { + (code, None) + }; + self.export_motion_csv(i, also); + } + if !open { + self.motion_results_open = false; + } + } + + /// Tables and charts of one run. `idx` salts the widget ids, because two + /// runs are on screen at once in the A-vs-B comparison. + fn report_body(ui: &mut egui::Ui, r: &MotionReport, idx: usize) { + ui.strong(&r.run_name); + ui.weak(format!( + "{} · reference phase {} · {} phase(s){}", + r.patient, + r.reference, + r.phases.len(), + r.reference_structure + .as_deref() + .map(|s| format!(" · reference structure: {s}")) + .unwrap_or_default() + )); + + // Displacement magnitude vs phase. + let mut series: Vec = Vec::new(); + for (i, t) in r.tracks.iter().enumerate() { + series.push(Series { + label: format!("{} ({})", t.target, t.model.label()), + color: track_color(i), + values: t.magnitudes(), + dashed: false, + }); + } + for t in &r.reference_tracks { + if t.model == MotionModel::Deformable || r.reference_tracks.len() == 1 { + series.push(Series { + label: format!("{} (reference)", t.target), + color: REF_COLOR, + values: t.magnitudes(), + dashed: true, + }); + break; + } + } + line_chart(ui, &r.phases, &series, "|d| mm"); + + // Peak-to-peak amplitudes and drift. + ui.add_space(6.0); + ui.strong("Peak-to-peak amplitude"); + let mut bars: Vec<(String, f64, Color32)> = Vec::new(); + for (i, t) in r.tracks.iter().enumerate() { + bars.push(( + format!("{} ({})", t.target, t.model.label()), + t.peak_to_peak(), + track_color(i), + )); + } + for t in &r.reference_tracks { + if t.model == MotionModel::Deformable || r.reference_tracks.len() == 1 { + bars.push(( + format!("{} (reference)", t.target), + t.peak_to_peak(), + REF_COLOR, + )); + break; + } + } + bar_rows(ui, &bars, "mm"); + if !r.reference_tracks.is_empty() { + ui.add_space(6.0); + ui.strong("Peak-to-peak target–reference drift"); + let mut bars: Vec<(String, f64, Color32)> = Vec::new(); + for (i, t) in r.tracks.iter().enumerate() { + if let Some(rt) = r.reference_track(t.model) { + if let Some(drift) = t.drift_against(rt) { + bars.push(( + format!("{} ({})", t.target, t.model.label()), + motion::peak_to_peak(&drift), + track_color(i), + )); + } + } + } + bar_rows(ui, &bars, "mm"); + } + + // The per-phase numbers. + egui::CollapsingHeader::new("Per-phase table") + .id_salt(("motion_table", idx)) + .show(ui, |ui| { + egui::Grid::new(("motion_grid", idx)) + .striped(true) + .show(ui, |ui| { + ui.strong("Phase"); + for t in r.tracks.iter().chain(&r.reference_tracks) { + ui.strong(format!("{} ({})\n|d| mm · cm³", t.target, t.model.label())); + } + ui.end_row(); + for (pi, ph) in r.phases.iter().enumerate() { + ui.label(ph); + for t in r.tracks.iter().chain(&r.reference_tracks) { + let d = t.magnitudes()[pi]; + let v = t.samples[pi].volume_cm3; + ui.label(format!("{d:.2} · {v:.2}")); + } + ui.end_row(); + } + }); + }); + + // Correlations. + if !r.correlations.is_empty() { + egui::CollapsingHeader::new("Target–reference synchrony (Pearson)") + .id_salt(("motion_corr", idx)) + .default_open(true) + .show(ui, |ui| { + for (target, model, axes) in &r.correlations { + ui.label(format!("{target} ({}):", model.label())); + for c in axes { + ui.weak(format!(" {}", c.line())); + } + } + }); + } + + // Registration quality. + if !r.qa.is_empty() { + egui::CollapsingHeader::new("Registration quality") + .id_salt(("motion_qa", idx)) + .show(ui, |ui| { + for q in &r.qa { + ui.weak(format!( + "{} ({}): {} · p95 {:.1} mm · folding {:.2} %", + q.phase, + q.model.label(), + q.metric_line, + q.disp_p95_mm, + q.folding_pct + )); + } + }); + } + + // ITVs. + if !r.itvs.is_empty() { + ui.add_space(6.0); + ui.strong("ITV volumes"); + for itv in &r.itvs { + ui.label(format!(" {} — {:.2} cm³", itv.seg_name, itv.volume_cm3)); + } + } + } + + /// The A-vs-B section: matched ITVs with the volume change, and matched + /// peak-to-peak amplitudes. + fn comparison_body(ui: &mut egui::Ui, a: &MotionReport, b: &MotionReport, idx: (usize, usize)) { + ui.strong(format!("{} vs {}", a.run_name, b.run_name)); + let mut any = false; + egui::Grid::new(("motion_cmp_grid", idx)) + .striped(true) + .show(ui, |ui| { + ui.strong("ITV"); + ui.strong(a.slot_label()); + ui.strong(b.slot_label()); + ui.strong("change"); + ui.end_row(); + for ia in &a.itvs { + let Some(ib) = b + .itvs + .iter() + .find(|x| x.target == ia.target && x.model == ia.model) + else { + continue; + }; + any = true; + let change = if ib.volume_cm3 > 1e-9 { + 100.0 * (ia.volume_cm3 - ib.volume_cm3) / ib.volume_cm3 + } else { + 0.0 + }; + ui.label(format!("{} ({})", ia.target, ia.model.label())); + ui.label(format!("{:.2} cm³", ia.volume_cm3)); + ui.label(format!("{:.2} cm³", ib.volume_cm3)); + ui.label(format!("{change:+.1} %")); + ui.end_row(); + } + }); + if !any { + ui.weak("No ITV appears in both runs under the same target name and model."); + } + // Peak-to-peak side by side. + let matched: Vec<(String, f64, f64)> = a + .tracks + .iter() + .filter_map(|ta| { + b.tracks + .iter() + .find(|tb| tb.target == ta.target && tb.model == ta.model) + .map(|tb| { + ( + format!("{} ({})", ta.target, ta.model.label()), + ta.peak_to_peak(), + tb.peak_to_peak(), + ) + }) + }) + .collect(); + if !matched.is_empty() { + ui.add_space(4.0); + ui.strong("Peak-to-peak amplitude"); + egui::Grid::new(("motion_cmp_pp", idx)) + .striped(true) + .show(ui, |ui| { + ui.strong("Track"); + ui.strong(a.slot_label()); + ui.strong(b.slot_label()); + ui.end_row(); + for (label, pa, pb) in matched { + ui.label(label); + ui.label(format!("{pa:.2} mm")); + ui.label(format!("{pb:.2} mm")); + ui.end_row(); + } + }); + } + } + + /// Write one run (or a run plus its comparison) as CSV, via a save + /// dialog. + fn export_motion_csv(&mut self, sel: usize, also: Option) { + let Some(r) = self.motion_reports.get(sel) else { + return; + }; + let mut csv = r.csv(); + if let Some(other) = also.and_then(|i| self.motion_reports.get(i)) { + // The header line of the second report is dropped — one file, + // one header. + if let Some(pos) = other.csv().find('\n') { + csv.push_str(&other.csv()[pos + 1..]); + } + } + let name = format!( + "motion_{}.csv", + r.run_name + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect::() + ); + if let Some(path) = rfd::FileDialog::new() + .set_file_name(&name) + .add_filter("CSV", &["csv"]) + .save_file() + { + match std::fs::write(&path, csv) { + Ok(()) => self.notice = Some(format!("✔ report written to {}", path.display())), + Err(e) => self.error = Some(format!("CSV export: {e}")), + } + } + } +} + +impl MotionReport { + /// `dataset A` — the comparison table's column header. + fn slot_label(&self) -> String { + format!("dataset {}", self.slot_name) + } +} diff --git a/src/app/motion_win.rs b/src/app/motion_win.rs new file mode 100644 index 0000000..23b1199 --- /dev/null +++ b/src/app/motion_win.rs @@ -0,0 +1,1017 @@ +//! *Tools ▶ 4D motion / ITV analysis*: the automated per-phase pipeline. +//! +//! One run reproduces the whole 4DCT motion workflow on a recognised 4D +//! group: the reference phase is registered to every other phase (rigidly, +//! and deformably on top of the rigid result), the chosen targets are +//! propagated through each transform, and what comes back is measured — +//! centroid trajectories, peak-to-peak amplitudes, drift against a +//! reference structure (typically the heart) with direction-wise +//! correlation, per-phase registration quality, and motion-encompassing +//! ITVs stored as segmentations on the reference phase. +//! +//! The dialog's settings survive as a *recipe*: the same targets (matched +//! by name), models and options can be re-applied to the other dataset or +//! to the next study with two clicks, which is what makes the workflow +//! practical over a cohort rather than a single case. + +use crate::dicomseg::{resample_mask, SegSeries}; +use crate::loader::SeriesInfo; +use crate::morphology; +use crate::motion::{ + self, AxisCorrelation, ItvResult, MotionModel, MotionReport, PhaseSample, RegQa, Track, +}; +use crate::propagate::{self, Subject}; +use crate::registration::RegParams; +use crate::rtstruct::Roi; +use crate::volume::Grid; + +use super::combine_win::ItemRef; +use super::*; + +/// The tool window's state; it stays open across runs. +pub(super) struct MotionDialog { + pub slot: usize, + /// Index into the study's `fourd_groups`. + pub group: usize, + /// Member position of the reference phase within the group. + pub reference: usize, + /// Ticks parallel to [`ViewerApp::combine_candidates`]. + pub targets: Vec, + /// Candidate index of the reference structure (drift / correlation). + pub ref_struct: Option, + pub rigid: bool, + pub deformable: bool, + pub build_itv: bool, + /// Uniform margin added to each ITV, mm. + pub itv_margin_mm: f64, + /// Also keep every propagated per-phase mask as a segmentation series + /// on its phase. + pub keep_phase_segs: bool, + // Registration settings for the per-phase runs. + pub levels: usize, + pub iterations: usize, + pub samples: usize, + pub grid_mm: f64, + pub threshold: f32, + pub status: Option, +} + +/// The dialog's transferable part: what to analyse and how, with targets +/// remembered by *name* so the same recipe applies to another dataset. +#[derive(Clone)] +pub(super) struct MotionRecipe { + pub targets: Vec, + pub ref_struct: Option, + pub rigid: bool, + pub deformable: bool, + pub build_itv: bool, + pub itv_margin_mm: f64, + pub keep_phase_segs: bool, + pub levels: usize, + pub iterations: usize, + pub samples: usize, + pub grid_mm: f64, + pub threshold: f32, +} + +/// One structure frozen for the worker thread. +struct Snapshot { + name: String, + color: [u8; 3], + src: Src, +} + +/// Where the structure's geometry comes from. +enum Src { + Contours(Roi), + Mask { mask: Vec, grid: Grid }, +} + +/// Everything a run needs, snapshotted when it starts. +struct MotionRequest { + run_name: String, + slot_name: String, + patient: String, + group_name: String, + study_uid: String, + /// The phase members, in temporal order: label + the series to load. + phases: Vec<(String, SeriesInfo)>, + /// Index of the reference phase within `phases`. + reference: usize, + targets: Vec, + ref_struct: Option, + models: Vec, + build_itv: bool, + itv_margin_mm: f64, + keep_phase_segs: bool, + params: RegParams, +} + +/// One finished segmentation series to add to the study. +pub(super) struct OutSeries { + pub label: String, + pub grid: Grid, + pub referenced_series_uid: String, + pub segs: Vec<(String, [u8; 3], Vec)>, +} + +/// What a finished run hands back. +pub(super) struct MotionOutcome { + pub report: MotionReport, + /// The ITVs, on the reference phase's lattice. + pub itv_series: Option, + /// Per-phase propagated masks, when the run kept them. + pub phase_series: Vec, + pub study_uid: String, +} + +impl ViewerApp { + /// Open the tool for `slot`, optionally pre-selecting a 4D group. + pub(super) fn open_motion_dialog(&mut self, slot: usize, group: Option) { + let n_cand = self.combine_candidates(slot).len(); + let (levels, iterations, samples, grid_mm, threshold) = ( + self.reg_levels, + self.reg_iterations, + self.reg_samples, + self.reg_grid_mm, + self.reg_threshold, + ); + let mut d = MotionDialog { + slot, + group: group.unwrap_or(0), + reference: 0, + targets: vec![false; n_cand], + ref_struct: None, + rigid: true, + deformable: true, + build_itv: true, + itv_margin_mm: 0.0, + keep_phase_segs: false, + levels, + iterations, + samples, + grid_mm, + threshold, + status: None, + }; + if let Some(study) = self.slots[slot].study.as_ref() { + if let Some(g) = study.fourd_groups.get(d.group) { + d.reference = g.default_reference().unwrap_or(0); + } + } + self.motion_dialog = Some(d); + } + + /// The name of one candidate item (without its set), for recipes. + pub(super) fn item_name(&self, slot: usize, item: ItemRef) -> Option { + let study = self.slots[slot].study.as_ref()?; + match item.kind { + SetKind::Structures => Some( + study + .structure_sets + .get(item.set)? + .rois + .get(item.idx)? + .name + .clone(), + ), + SetKind::Segmentations => Some( + study + .seg_series + .get(item.set)? + .segs + .get(item.idx)? + .name + .clone(), + ), + } + } + + /// Freeze one candidate for the worker thread. + fn snapshot(&self, slot: usize, item: ItemRef) -> Option { + let study = self.slots[slot].study.as_ref()?; + match item.kind { + SetKind::Structures => { + let roi = study.structure_sets.get(item.set)?.rois.get(item.idx)?; + Some(Snapshot { + name: roi.name.clone(), + color: roi.color, + src: Src::Contours(roi.clone()), + }) + } + SetKind::Segmentations => { + let ser = study.seg_series.get(item.set)?; + let seg = ser.segs.get(item.idx)?; + Some(Snapshot { + name: seg.name.clone(), + color: seg.color, + src: Src::Mask { + mask: seg.mask.clone(), + grid: ser.grid.clone(), + }, + }) + } + } + } + + /// The current dialog as a name-based recipe. + fn motion_recipe_of(&self, d: &MotionDialog) -> MotionRecipe { + let cands = self.combine_candidates(d.slot); + let name_of = |i: usize| { + cands + .get(i) + .and_then(|(r, _)| self.item_name(d.slot, *r)) + .unwrap_or_default() + }; + MotionRecipe { + targets: d + .targets + .iter() + .enumerate() + .filter(|(_, &on)| on) + .map(|(i, _)| name_of(i)) + .filter(|n| !n.is_empty()) + .collect(), + ref_struct: d.ref_struct.map(name_of).filter(|n| !n.is_empty()), + rigid: d.rigid, + deformable: d.deformable, + build_itv: d.build_itv, + itv_margin_mm: d.itv_margin_mm, + keep_phase_segs: d.keep_phase_segs, + levels: d.levels, + iterations: d.iterations, + samples: d.samples, + grid_mm: d.grid_mm, + threshold: d.threshold, + } + } + + /// Tick the dialog's lists from a recipe, matching items by name. + fn apply_motion_recipe(&self, d: &mut MotionDialog, r: &MotionRecipe) { + let cands = self.combine_candidates(d.slot); + d.targets = vec![false; cands.len()]; + d.ref_struct = None; + for (i, (item, _)) in cands.iter().enumerate() { + let Some(name) = self.item_name(d.slot, *item) else { + continue; + }; + if r.targets.contains(&name) { + d.targets[i] = true; + } + if d.ref_struct.is_none() && r.ref_struct.as_deref() == Some(name.as_str()) { + d.ref_struct = Some(i); + } + } + d.rigid = r.rigid; + d.deformable = r.deformable; + d.build_itv = r.build_itv; + d.itv_margin_mm = r.itv_margin_mm; + d.keep_phase_segs = r.keep_phase_segs; + d.levels = r.levels; + d.iterations = r.iterations; + d.samples = r.samples; + d.grid_mm = r.grid_mm; + d.threshold = r.threshold; + } + + /// Start the pipeline on a worker thread. + fn start_motion_run(&mut self) { + if self.motion_job.is_some() { + return; + } + let Some(d) = &self.motion_dialog else { + return; + }; + let slot = d.slot; + let req = match self.build_motion_request(d) { + Ok(r) => r, + Err(e) => { + if let Some(d) = &mut self.motion_dialog { + d.status = Some(format!("{e:#}")); + } + return; + } + }; + self.motion_recipe = Some(self.motion_recipe_of(d)); + self.motion_slot = slot; + let progress = Arc::new(Progress::default()); + progress.set("starting…"); + self.motion_job = Some(Job::spawn(progress, move |p| (slot, run_motion(req, p)))); + } + + /// Snapshot everything the worker needs, or say what is missing. + fn build_motion_request(&self, d: &MotionDialog) -> anyhow::Result { + use anyhow::{bail, Context}; + let slot = d.slot; + let Some(study) = self.slots[slot].study.as_ref() else { + bail!("dataset {} is not loaded", SLOT_NAMES[slot]); + }; + let Some(group) = study.fourd_groups.get(d.group) else { + bail!("no 4D group selected"); + }; + let resolved = group.resolve(&study.series); + let mut phases = Vec::new(); + let mut reference = None; + for (mi, m) in group.members.iter().enumerate() { + if m.role != crate::fourd::Role::Phase { + continue; + } + let Some(si) = resolved[mi] else { + bail!("phase '{}' has no series any more", m.label); + }; + if mi == d.reference { + reference = Some(phases.len()); + } + phases.push((m.label.clone(), study.series[si].clone())); + } + if phases.len() < 2 { + bail!("the group needs at least two phases"); + } + let reference = reference.context("the reference must be one of the phases")?; + + let cands = self.combine_candidates(slot); + let mut targets = Vec::new(); + for (i, &on) in d.targets.iter().enumerate() { + if !on { + continue; + } + let (item, label) = &cands[i]; + targets.push( + self.snapshot(slot, *item) + .with_context(|| format!("'{label}' is gone"))?, + ); + } + if targets.is_empty() { + bail!("tick at least one target structure"); + } + let ref_struct = match d.ref_struct { + Some(i) => { + let (item, label) = cands + .get(i) + .context("the reference-structure choice is stale — pick it again")?; + let s = self + .snapshot(slot, *item) + .with_context(|| format!("'{label}' is gone"))?; + if targets.iter().any(|t| t.name == s.name) { + bail!("'{}' cannot be both target and reference", s.name); + } + Some(s) + } + None => None, + }; + let mut models = Vec::new(); + if d.rigid { + models.push(MotionModel::Rigid); + } + if d.deformable { + models.push(MotionModel::Deformable); + } + if models.is_empty() { + bail!("choose at least one model (rigid / deformable)"); + } + let params = RegParams { + method: RegMethod::ElastixRigid, + levels: d.levels, + iterations: d.iterations, + samples: d.samples, + grid_spacing_mm: d.grid_mm, + fixed_threshold: d.threshold, + ..RegParams::default() + }; + Ok(MotionRequest { + // Numbered, so two runs on the same group stay distinguishable + // in the results window's pick lists. + run_name: format!( + "#{} {} · {} · ref {}", + self.motion_reports.len() + 1, + SLOT_NAMES[slot], + group.name, + phases[reference].0 + ), + slot_name: SLOT_NAMES[slot].to_string(), + patient: study.meta.patient_name.replace('^', " "), + group_name: group.name.clone(), + study_uid: group.study_uid.clone(), + phases, + reference, + targets, + ref_struct, + models, + build_itv: d.build_itv, + itv_margin_mm: d.itv_margin_mm, + keep_phase_segs: d.keep_phase_segs, + params, + }) + } + + /// Land a finished run: file its segmentations, keep its report, open + /// the results. + pub(super) fn on_motion_done(&mut self, slot: usize, outcome: MotionOutcome) { + let mut lines = vec![format!( + "Motion analysis finished: {}", + outcome.report.run_name + )]; + // The report is kept whatever happened to the dataset meanwhile — + // it is self-contained — but segmentations only land in the study + // the run analysed. + let still_there = self.slots[slot] + .study + .as_ref() + .is_some_and(|st| st.series.iter().any(|se| se.study_uid == outcome.study_uid)); + if !still_there { + lines.push("The dataset changed while it ran — segmentations were discarded.".into()); + self.motion_reports.push(outcome.report); + self.motion_sel = self.motion_reports.len() - 1; + self.motion_results_open = true; + if let Some(d) = &mut self.motion_dialog { + d.status = Some(lines.join(" ")); + } + return; + } + if let Some(study) = self.slots[slot].study.as_mut() { + let mut add = |o: OutSeries| { + let mut ser = SegSeries::new( + o.label, + o.grid, + o.referenced_series_uid, + outcome.study_uid.clone(), + ); + for (name, color, mask) in o.segs { + ser.segs.push(Segmentation::from_label_map( + name, + color, + ser.grid.dims, + &mask, + 1, + )); + } + study.seg_series.push(ser); + }; + if let Some(itv) = outcome.itv_series { + lines.push(format!( + "ITVs stored as segmentation series '{}' on the reference phase.", + itv.label + )); + add(itv); + } + let n_phase = outcome.phase_series.len(); + for o in outcome.phase_series { + add(o); + } + if n_phase > 0 { + lines.push(format!("{n_phase} per-phase series kept.")); + } + } + self.rebind_seg_series(slot); + self.motion_reports.push(outcome.report); + self.motion_sel = self.motion_reports.len() - 1; + self.motion_results_open = true; + if let Some(d) = &mut self.motion_dialog { + d.status = Some(lines.join(" ")); + } + } + + // ---- the window -------------------------------------------------------- + + pub(super) fn motion_window(&mut self, ctx: &egui::Context) { + let Some(d) = &self.motion_dialog else { + return; + }; + let slot = d.slot; + if self.slots[slot].study.is_none() { + self.motion_dialog = None; + return; + } + let cands = self.combine_candidates(slot); + // (group index, group name, [(member position, phase label)]). + type GroupRow = (usize, String, Vec<(usize, String)>); + let groups: Vec = self.slots[slot] + .study + .as_ref() + .map(|st| { + st.fourd_groups + .iter() + .enumerate() + .map(|(gi, g)| { + let phase_positions = g + .members + .iter() + .enumerate() + .filter(|(_, m)| m.role == crate::fourd::Role::Phase) + .map(|(mi, m)| (mi, m.label.clone())) + .collect(); + (gi, g.name.clone(), phase_positions) + }) + .collect() + }) + .unwrap_or_default(); + + let running = self + .motion_job + .as_ref() + .filter(|_| self.motion_slot == slot); + let progress = running.map(|j| j.progress.clone()); + let has_recipe = self.motion_recipe.is_some(); + + let mut run = false; + let mut cancel = false; + let mut close = false; + let mut apply_recipe = false; + let mut open = true; + + let d = self.motion_dialog.as_mut().expect("checked above"); + detach::tool_window( + ctx, + "motion", + MOTION.title(slot), + &mut open, + detach::WinOpts::default().resizable(false), + |ui| { + ui.label( + "Register the reference phase to every phase of a 4D group, carry the \ + targets across, and measure their motion — trajectories, amplitudes, \ + drift against a reference structure, and the ITV.", + ); + ui.add_space(4.0); + if groups.is_empty() { + ui.colored_label( + warn_color(ui.visuals()), + "No 4D group in this dataset. Phases are recognised from the series \ + descriptions (e.g. \"… 30%\"); series can also be grouped by hand \ + from the data tree (right-click a series ▸ 4D group).", + ); + return; + } + d.group = d.group.min(groups.len() - 1); + let (_, gname, _) = &groups[d.group]; + ui.horizontal(|ui| { + ui.label("4D group:"); + egui::ComboBox::from_id_salt("motion_group") + .width(280.0) + .selected_text(gname.clone()) + .show_ui(ui, |ui| { + for (gi, name, _) in &groups { + ui.selectable_value(&mut d.group, *gi, name); + } + }); + }); + let (_, _, phases) = &groups[d.group]; + if !phases.iter().any(|(mi, _)| *mi == d.reference) { + d.reference = phases.first().map(|(mi, _)| *mi).unwrap_or(0); + } + ui.horizontal(|ui| { + ui.label("Reference phase:"); + let sel = phases + .iter() + .find(|(mi, _)| *mi == d.reference) + .map(|(_, l)| l.clone()) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("motion_ref") + .selected_text(sel) + .show_ui(ui, |ui| { + for (mi, label) in phases { + ui.selectable_value(&mut d.reference, *mi, label); + } + }); + ui.label("·").on_hover_text( + "Targets are defined on this phase and carried to the others", + ); + }); + ui.separator(); + + ui.label("Targets (defined on / resampled to the reference phase):"); + d.targets.resize(cands.len(), false); + // The candidate list shrinks when sets are removed while + // the window is open; a stale pick must not survive it. + if d.ref_struct.is_some_and(|i| i >= cands.len()) { + d.ref_struct = None; + } + egui::ScrollArea::vertical() + .id_salt("motion_targets") + .max_height(120.0) + .show(ui, |ui| { + for (i, (_, label)) in cands.iter().enumerate() { + ui.checkbox(&mut d.targets[i], label); + } + if cands.is_empty() { + ui.weak("no structures or segmentations in this dataset"); + } + }); + ui.horizontal(|ui| { + ui.label("Reference structure:"); + let sel = d + .ref_struct + .and_then(|i| cands.get(i).map(|(_, l)| l.clone())) + .unwrap_or_else(|| "(none)".into()); + egui::ComboBox::from_id_salt("motion_refstruct") + .width(260.0) + .selected_text(sel) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.ref_struct, None, "(none)"); + for (i, (_, label)) in cands.iter().enumerate() { + ui.selectable_value(&mut d.ref_struct, Some(i), label); + } + }); + }) + .response + .on_hover_text( + "Carried along for target–reference drift and direction-wise \ + correlation — typically the heart for cardiac targets", + ); + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Models:"); + ui.checkbox(&mut d.rigid, "rigid"); + ui.checkbox(&mut d.deformable, "deformable") + .on_hover_text("B-spline refinement on top of the rigid result"); + }); + ui.horizontal(|ui| { + ui.checkbox(&mut d.build_itv, "Build ITV").on_hover_text( + "Union of the target over all phases, on the reference phase", + ); + ui.add_enabled( + d.build_itv, + egui::DragValue::new(&mut d.itv_margin_mm) + .speed(0.5) + .range(0.0..=30.0) + .prefix("+ ") + .suffix(" mm"), + ) + .on_hover_text("Uniform margin added to the union"); + }); + ui.checkbox(&mut d.keep_phase_segs, "Keep per-phase segmentations") + .on_hover_text( + "Store every propagated mask as a segmentation series on its phase \ + — one series per phase", + ); + egui::CollapsingHeader::new("Registration settings") + .default_open(false) + .show(ui, |ui| { + egui::Grid::new("motion_reg").num_columns(2).show(ui, |ui| { + ui.label("Resolution levels:"); + ui.add(egui::DragValue::new(&mut d.levels).range(1..=5)); + ui.end_row(); + ui.label("Iterations / level:"); + ui.add(egui::DragValue::new(&mut d.iterations).range(50..=2000)); + ui.end_row(); + ui.label("Samples / iteration:"); + ui.add(egui::DragValue::new(&mut d.samples).range(500..=20000)); + ui.end_row(); + ui.label("B-spline grid (mm):"); + ui.add( + egui::DragValue::new(&mut d.grid_mm) + .speed(1.0) + .range(8.0..=100.0), + ); + ui.end_row(); + ui.label("Sampling threshold (HU):"); + ui.add(egui::DragValue::new(&mut d.threshold).speed(10.0)); + ui.end_row(); + }); + ui.weak( + "Elastix rigid, then B-spline refinement — the same engines \ + as the Registration panel.", + ); + }); + ui.add_space(4.0); + if let Some(status) = &d.status { + ui.label(status.clone()); + } + match &progress { + Some(p) => { + if seg_engines::progress_row(ui, p) { + cancel = true; + } + } + None => { + ui.horizontal(|ui| { + if ui.button("▶ Analyse").clicked() { + run = true; + } + if ui + .add_enabled(has_recipe, egui::Button::new("Apply last recipe")) + .on_hover_text( + "Tick the same targets (matched by name) and re-use the \ + options of the previous run — for the other dataset or \ + the next study", + ) + .clicked() + { + apply_recipe = true; + } + if ui.button("Close").clicked() { + close = true; + } + }); + } + } + }, + ); + if cancel { + if let Some(job) = &self.motion_job { + job.progress.cancel(); + } + } + if apply_recipe { + if let (Some(mut d), Some(r)) = (self.motion_dialog.take(), self.motion_recipe.clone()) + { + self.apply_motion_recipe(&mut d, &r); + self.motion_dialog = Some(d); + } + } + if run { + self.start_motion_run(); + } + if close || !open { + self.motion_dialog = None; + } + } +} + +// ---- the pipeline itself --------------------------------------------------- + +/// Rasterize / resample a snapshot onto `grid`. +fn mask_on(s: &Snapshot, grid: &Grid) -> anyhow::Result> { + use anyhow::{bail, Context}; + let mask = match &s.src { + Src::Contours(roi) => segmentation::rasterize_roi(grid, roi) + .with_context(|| format!("'{}' has no contour inside the reference phase", s.name))?, + Src::Mask { mask, grid: from } => { + if from.matches(grid) { + mask.clone() + } else { + resample_mask(mask, from, grid) + } + } + }; + if mask.iter().all(|&v| v == 0) { + bail!("'{}' is empty on the reference phase", s.name); + } + Ok(mask) +} + +/// The whole per-phase pipeline, on the worker thread. +fn run_motion(req: MotionRequest, p: &Progress) -> anyhow::Result { + use anyhow::{anyhow, bail}; + let n = req.phases.len(); + let n_targets = req.targets.len(); + let cancelled = || anyhow!(progress::CANCELLED); + + // The reference phase. + p.set_phase(0.0, 0.04); + let (ref_vol, _, _) = loader::load_series_volume(&req.phases[req.reference].1, p)?; + let ref_grid = ref_vol.grid(); + + // All structures on the reference lattice: targets first, then the + // reference structure. + let mut subjects: Vec = Vec::new(); + for s in req.targets.iter().chain(req.ref_struct.iter()) { + subjects.push(Subject { + name: s.name.clone(), + color: s.color, + mask: mask_on(s, &ref_grid)?, + }); + } + let n_subjects = subjects.len(); + + // samples[model][subject][phase] — filled as the phases are processed. + let mut samples: Vec>>> = + vec![vec![vec![None; n]; n_subjects]; req.models.len()]; + // Union accumulators on the reference grid, [model][target]. + let ref_n = ref_grid.dims[0] * ref_grid.dims[1] * ref_grid.dims[2]; + let mut unions: Vec>> = if req.build_itv { + vec![vec![vec![0u8; ref_n]; n_targets]; req.models.len()] + } else { + Vec::new() + }; + let mut qa: Vec = Vec::new(); + let mut phase_series: Vec = Vec::new(); + + // The reference phase's own samples (and its contribution to the ITV). + for (mi, _) in req.models.iter().enumerate() { + for (si, subject) in subjects.iter().enumerate() { + let c = motion::centroid_mm(&subject.mask, &ref_grid) + .ok_or_else(|| anyhow!("'{}' is empty", subject.name))?; + samples[mi][si][req.reference] = Some(PhaseSample { + phase: req.phases[req.reference].0.clone(), + centroid: c, + volume_cm3: motion::volume_cm3(&subject.mask, &ref_grid), + }); + if req.build_itv && si < n_targets { + motion::union_into(&mut unions[mi][si], &subject.mask); + } + } + } + + // Every other phase: register, propagate, measure. + let others: Vec = (0..n).filter(|&i| i != req.reference).collect(); + for (oi, &pi) in others.iter().enumerate() { + if p.cancelled() { + return Err(cancelled()); + } + let base = 0.05 + 0.9 * oi as f32 / others.len() as f32; + let span = 0.9 / others.len() as f32; + let (label, series) = &req.phases[pi]; + p.set_phase(base, span * 0.15); + p.set(format!("Phase {label}: loading…")); + let (vol, _, _) = loader::load_series_volume(series, p)?; + let phase_grid = vol.grid(); + + p.set_phase(base + span * 0.15, span * 0.35); + p.set(format!("Phase {label}: rigid registration…")); + let mut params = req.params.clone(); + params.method = RegMethod::ElastixRigid; + let rigid = registration::register(&ref_vol, &vol, ¶ms, p)?; + qa.push(RegQa { + phase: label.clone(), + model: MotionModel::Rigid, + metric_line: rigid.metric_line(), + folding_pct: 100.0 * rigid.analysis.jacobian.folded, + disp_p95_mm: rigid.analysis.displacement.p95, + }); + + let deformable = if req.models.contains(&MotionModel::Deformable) { + p.set_phase(base + span * 0.5, span * 0.35); + p.set(format!("Phase {label}: deformable refinement…")); + let mut params = req.params.clone(); + params.method = RegMethod::ElastixBSpline; + params.start = Some(rigid.transform.clone()); + let def = registration::register(&ref_vol, &vol, ¶ms, p)?; + qa.push(RegQa { + phase: label.clone(), + model: MotionModel::Deformable, + metric_line: def.metric_line(), + folding_pct: 100.0 * def.analysis.jacobian.folded, + disp_p95_mm: def.analysis.displacement.p95, + }); + Some(def) + } else { + None + }; + + p.set_phase(base + span * 0.85, span * 0.15); + let mut phase_out: Vec<(String, [u8; 3], Vec)> = Vec::new(); + for (mi, model) in req.models.iter().enumerate() { + let transform = match model { + MotionModel::Rigid => &rigid.transform, + MotionModel::Deformable => &deformable.as_ref().expect("built above").transform, + }; + p.set(format!("Phase {label}: propagating ({})…", model.label())); + // The transform maps reference → phase; landing on the phase + // lattice therefore samples through the inverse. + let props = propagate::propagate(&ref_vol, &vol, transform, true, &subjects, p)?; + for (si, prop) in props.iter().enumerate() { + let c = motion::centroid_mm(&prop.mask, &phase_grid).ok_or_else(|| { + anyhow!( + "'{}' vanished on phase {label} ({})", + prop.name, + model.label() + ) + })?; + samples[mi][si][pi] = Some(PhaseSample { + phase: label.clone(), + centroid: c, + volume_cm3: prop.result_cm3, + }); + if req.build_itv && si < n_targets { + let on_ref = resample_mask(&prop.mask, &phase_grid, &ref_grid); + motion::union_into(&mut unions[mi][si], &on_ref); + } + if req.keep_phase_segs { + phase_out.push(( + format!("{} ({label}, {})", prop.name, model.label()), + prop.color, + prop.mask.clone(), + )); + } + } + } + if !phase_out.is_empty() { + phase_series.push(OutSeries { + label: format!("4D {label} — {}", req.group_name), + grid: phase_grid, + referenced_series_uid: series.uid.clone(), + segs: phase_out, + }); + } + } + if p.cancelled() { + return Err(cancelled()); + } + p.set_phase(0.95, 0.05); + p.set("Assembling the report…"); + + // Tracks in phase order. + let mut tracks = Vec::new(); + let mut reference_tracks = Vec::new(); + for (mi, model) in req.models.iter().enumerate() { + for (si, subject) in subjects.iter().enumerate() { + let track = Track { + target: subject.name.clone(), + model: *model, + samples: samples[mi][si] + .iter() + .map(|s| s.clone().expect("every phase was filled")) + .collect(), + reference: req.reference, + }; + if si < n_targets { + tracks.push(track); + } else { + reference_tracks.push(track); + } + } + } + + // Correlation of every target against the reference structure. + let mut correlations = Vec::new(); + for t in &tracks { + let Some(rt) = reference_tracks.iter().find(|r| r.model == t.model) else { + continue; + }; + let td = t.displacements(); + let rd = rt.displacements(); + let comp = |v: &[crate::geometry::Vec3], a: usize| -> Vec { + v.iter().map(|p| [p.x, p.y, p.z][a]).collect() + }; + let mut axes = Vec::new(); + for (a, name) in motion::AXES.iter().enumerate() { + if let Some((r, pv)) = motion::pearson(&comp(&td, a), &comp(&rd, a)) { + axes.push(AxisCorrelation { + axis: name, + r, + p: pv, + }); + } + } + if !axes.is_empty() { + correlations.push((t.target.clone(), t.model, axes)); + } + } + + // The ITVs. + let mut itvs = Vec::new(); + let mut itv_segs: Vec<(String, [u8; 3], Vec)> = Vec::new(); + if req.build_itv { + for (mi, model) in req.models.iter().enumerate() { + for (si, target) in req.targets.iter().enumerate() { + let mut mask = std::mem::take(&mut unions[mi][si]); + if req.itv_margin_mm > 0.0 { + mask = morphology::dilate_mm( + &mask, + ref_grid.dims, + ref_grid.spacing, + req.itv_margin_mm, + ); + } + let name = if req.itv_margin_mm > 0.0 { + format!( + "ITV {} +{:.0}mm ({})", + target.name, + req.itv_margin_mm, + model.label() + ) + } else { + format!("ITV {} ({})", target.name, model.label()) + }; + itvs.push(ItvResult { + target: target.name.clone(), + model: *model, + margin_mm: req.itv_margin_mm, + volume_cm3: motion::volume_cm3(&mask, &ref_grid), + seg_name: name.clone(), + }); + itv_segs.push((name, target.color, mask)); + } + } + } + if subjects.is_empty() { + bail!("nothing to analyse"); + } + + let report = MotionReport { + run_name: req.run_name, + slot_name: req.slot_name, + patient: req.patient, + phases: req.phases.iter().map(|(l, _)| l.clone()).collect(), + reference: req.phases[req.reference].0.clone(), + tracks, + reference_tracks, + reference_structure: req.ref_struct.as_ref().map(|s| s.name.clone()), + correlations, + qa, + itvs, + }; + Ok(MotionOutcome { + report, + itv_series: (!itv_segs.is_empty()).then(|| OutSeries { + label: format!("4D ITV — {}", req.group_name), + grid: ref_grid, + referenced_series_uid: req.phases[req.reference].1.uid.clone(), + segs: itv_segs, + }), + phase_series, + study_uid: req.study_uid, + }) +} diff --git a/src/app/pacs_win.rs b/src/app/pacs_win.rs new file mode 100644 index 0000000..c4e2ec4 --- /dev/null +++ b/src/app/pacs_win.rs @@ -0,0 +1,461 @@ +//! *Tools ▶ 🏥 PACS*: the local patient archive as a window. +//! +//! Everything the archive can do reduces to three gestures — file a folder +//! into it, take a patient or a study out of it into a viewer dataset, and +//! give back what was drawn on one — and each is one button here. The +//! archive itself ([`crate::archive`]) knows nothing about the UI; this +//! window is the part that knows which dataset the user meant. +//! +//! Loading needs no special path: a study folder in the archive *is* a DICOM +//! folder, so it goes through the same `loader::load_directory` as *File ▶ +//! Add DICOM folder*, with the same merging and the same progress. + +use crate::archive::{Archive, ImportSummary, PatientEntry}; + +use super::*; + +/// What the PACS window is showing and doing. +pub(super) struct PacsWindow { + /// Archive root as edited in the window. + pub dir: String, + /// The last scan; `None` until one has been made. + pub patients: Option>, + /// Which patient row is expanded, by index into `patients`. + pub expanded: Option, + /// The selected study, as (patient index, study index). A patient with + /// no study selected means "the whole patient". + pub selected: Option<(usize, Option)>, + pub status: Option, +} + +impl PacsWindow { + fn new(dir: String) -> PacsWindow { + PacsWindow { + dir, + patients: None, + expanded: None, + selected: None, + status: None, + } + } +} + +/// What a background archive job answers with. +pub(super) enum PacsOutcome { + Scanned(Vec), + Imported(ImportSummary), + /// Objects written back into the archive: how many, and where. + Uploaded(usize, String), + Removed, +} + +impl ViewerApp { + pub(super) fn open_pacs_window(&mut self) { + if self.pacs.is_none() { + let dir = if self.archive_dir.trim().is_empty() { + crate::archive::default_root().display().to_string() + } else { + self.archive_dir.clone() + }; + self.pacs = Some(PacsWindow::new(dir)); + self.start_pacs_scan(); + } + } + + /// The archive root the window is pointed at. + fn pacs_root(&self) -> std::path::PathBuf { + crate::archive::root_from_setting(self.pacs.as_ref().map(|p| p.dir.as_str()).unwrap_or("")) + } + + pub(super) fn start_pacs_scan(&mut self) { + if self.pacs_job.is_some() { + return; + } + let root = self.pacs_root(); + let progress = Arc::new(Progress::default()); + progress.set("Reading the archive…"); + self.pacs_job = Some(Job::spawn(progress, move |_| { + Archive::new(root).scan().map(PacsOutcome::Scanned) + })); + } + + fn start_pacs_import(&mut self, src: std::path::PathBuf) { + if self.pacs_job.is_some() { + return; + } + let root = self.pacs_root(); + let progress = Arc::new(Progress::default()); + self.pacs_job = Some(Job::spawn(progress, move |p| { + Archive::new(root) + .import(&src, p) + .map(PacsOutcome::Imported) + })); + } + + /// Write the structure sets and segmentation series of a dataset back + /// into the archive. + /// + /// They are written to a scratch folder first and then imported, so the + /// filing rule lives in exactly one place — the archive — and an upload + /// that fails half way leaves the archive untouched rather than + /// half-written. + fn start_pacs_upload(&mut self, slot: usize) { + if self.pacs_job.is_some() { + return; + } + let Some(study) = self.slots[slot].study.as_ref() else { + self.error = Some(format!("dataset {} is not loaded", SLOT_NAMES[slot])); + return; + }; + let derived = study.structure_sets.iter().any(|ss| !ss.rois.is_empty()) + || study + .seg_series + .iter() + .any(|sr| sr.segs.iter().any(|s| s.count > 0)); + if !derived { + self.error = Some(format!( + "dataset {} has no structure sets or segmentations to send", + SLOT_NAMES[slot] + )); + return; + } + let study = study.clone(); + let params = dicom_export::ExportParams::for_study(&study); + let root = self.pacs_root(); + let scratch = std::env::temp_dir().join(format!( + "rds_upload_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + let progress = Arc::new(Progress::default()); + self.pacs_job = Some(Job::spawn(progress, move |p| { + p.set("Writing the derived objects…"); + let n = dicom_export::export_derived(&study, &scratch, ¶ms, p)?; + let archive = Archive::new(root); + let sum = archive.import(&scratch, p)?; + let _ = std::fs::remove_dir_all(&scratch); + Ok(PacsOutcome::Uploaded(n, sum.describe())) + })); + } + + fn start_pacs_remove(&mut self, dir: std::path::PathBuf) { + if self.pacs_job.is_some() { + return; + } + let root = self.pacs_root(); + let progress = Arc::new(Progress::default()); + progress.set("Removing…"); + self.pacs_job = Some(Job::spawn(progress, move |_| { + Archive::new(root) + .remove(&dir) + .map(|()| PacsOutcome::Removed) + })); + } + + /// An archive job finished. + pub(super) fn on_pacs_done(&mut self, outcome: PacsOutcome) { + let mut rescan = false; + if let Some(w) = &mut self.pacs { + match outcome { + PacsOutcome::Scanned(patients) => { + w.status = Some(format!( + "{} patient(s), {} study(ies)", + patients.len(), + patients.iter().map(|p| p.studies.len()).sum::() + )); + // A selection that the rescan invalidated must go, or the + // next click would act on a row that has moved. + if w.selected + .map(|(pi, si)| match si { + Some(si) => patients + .get(pi) + .map(|p| si >= p.studies.len()) + .unwrap_or(true), + None => pi >= patients.len(), + }) + .unwrap_or(false) + { + w.selected = None; + } + w.expanded = w.expanded.filter(|i| *i < patients.len()); + w.patients = Some(patients); + } + PacsOutcome::Imported(sum) => { + w.status = Some(format!("✔ imported: {}", sum.describe())); + rescan = true; + } + PacsOutcome::Uploaded(n, filed) => { + w.status = Some(format!("✔ {n} object(s) sent — {filed}")); + rescan = true; + } + PacsOutcome::Removed => { + w.status = Some("✔ removed".into()); + rescan = true; + } + } + } + if rescan { + self.start_pacs_scan(); + } + } + + pub(super) fn pacs_window(&mut self, ctx: &egui::Context) { + if self.pacs.is_none() { + return; + } + let mut open = true; + let mut close = false; + let mut rescan = false; + let mut browse = false; + let mut import = false; + let mut load: Option<(usize, std::path::PathBuf)> = None; + let mut upload: Option = None; + let mut remove: Option = None; + let mut expand: Option> = None; + let mut select: Option<(usize, Option)> = None; + let mut commit_dir = false; + + let busy = self.pacs_job.is_some(); + let loaded: [bool; 2] = [self.slots[0].study.is_some(), self.slots[1].study.is_some()]; + let mut w = self.pacs.take().expect("checked above"); + + detach::tool_window( + ctx, + "pacs", + "🏥 PACS — patient archive", + &mut open, + detach::WinOpts::size(720.0, 520.0), + |ui| { + ui.label( + "The local archive: every study filed here, ready to be taken into a \ + dataset and given back the structures and segmentations drawn on it.", + ); + ui.separator(); + + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Archive").strong()); + let resp = ui.add( + egui::TextEdit::singleline(&mut w.dir) + .desired_width(360.0) + .hint_text("archive folder"), + ); + // Committed when the field is left, not on every + // keystroke: a half-typed path is not a folder to scan. + if resp.lost_focus() { + commit_dir = true; + } + if ui.button("📂 Browse…").clicked() { + browse = true; + } + if ui + .add_enabled(!busy, egui::Button::new("⟲ Rescan")) + .clicked() + { + rescan = true; + } + }); + ui.horizontal(|ui| { + if ui + .add_enabled(!busy, egui::Button::new("📥 Import folder…")) + .on_hover_text("Copy every DICOM file of a folder into the archive") + .clicked() + { + import = true; + } + for (slot, name) in SLOT_NAMES.iter().enumerate() { + if ui + .add_enabled( + !busy && loaded[slot], + egui::Button::new(format!("📤 Send dataset {name}")), + ) + .on_hover_text( + "Write this dataset's structure sets and segmentation \ + series back into the archive, attached to the study they \ + belong to (new SOP Instance UIDs, original Study and \ + Frame of Reference UIDs). Images are never re-sent.", + ) + .clicked() + { + upload = Some(slot); + } + } + }); + + if let Some(job) = &self.pacs_job { + ui.separator(); + progress_row(ui, &job.progress); + } + if let Some(s) = &w.status { + ui.weak(s); + } + ui.separator(); + + let Some(patients) = &w.patients else { + ui.weak("reading…"); + return; + }; + if patients.is_empty() { + ui.weak("The archive is empty — 📥 Import folder… files a study into it."); + return; + } + egui::ScrollArea::vertical() + .max_height(320.0) + .show(ui, |ui| { + for (pi, p) in patients.iter().enumerate() { + let is_open = w.expanded == Some(pi); + ui.horizontal(|ui| { + if ui.small_button(if is_open { "▼" } else { "▶" }).clicked() { + expand = Some(if is_open { None } else { Some(pi) }); + } + let resp = ui.add( + egui::Button::selectable( + w.selected == Some((pi, None)), + format!( + "{} {} study(ies) · {} file(s)", + p.title(), + p.studies.len(), + p.files() + ), + ) + .wrap(), + ); + if resp.clicked() { + select = Some((pi, None)); + expand = Some(Some(pi)); + } + resp.context_menu(|ui| { + if ui.button("🗑 Remove this patient…").clicked() { + remove = Some(p.dir.clone()); + ui.close(); + } + }); + }); + if !is_open { + continue; + } + for (si, st) in p.studies.iter().enumerate() { + ui.horizontal(|ui| { + ui.add_space(24.0); + let resp = ui.add( + egui::Button::selectable( + w.selected == Some((pi, Some(si))), + st.describe(), + ) + .wrap(), + ); + if resp.clicked() { + select = Some((pi, Some(si))); + } + let resp = resp.on_hover_text(format!( + "Study UID …{}\n{}", + tail(&st.study_uid), + st.dir.display() + )); + resp.context_menu(|ui| { + if ui.button("🗑 Remove this study…").clicked() { + remove = Some(st.dir.clone()); + ui.close(); + } + }); + }); + } + } + }); + + ui.separator(); + let picked = w.selected.and_then(|(pi, si)| { + let p = patients.get(pi)?; + Some(match si { + Some(si) => (p.studies.get(si)?.dir.clone(), st_label(p, si)), + None => (p.dir.clone(), p.title()), + }) + }); + ui.horizontal(|ui| { + for (slot, name) in SLOT_NAMES.iter().enumerate() { + if ui + .add_enabled( + !busy && picked.is_some(), + egui::Button::new(format!("📩 Load into dataset {name}")), + ) + .on_hover_text( + "Read the selection into this dataset, merging it with \ + whatever is already there — the same as adding its folder", + ) + .clicked() + { + if let Some((dir, _)) = &picked { + load = Some((slot, dir.clone())); + } + } + } + match &picked { + Some((_, label)) => ui.weak(label.clone()), + None => ui.weak("select a patient or a study"), + }; + }); + ui.add_space(4.0); + if ui.button("Close").clicked() { + close = true; + } + }, + ); + + if let Some(e) = expand { + w.expanded = e; + } + if let Some(s) = select { + w.selected = Some(s); + } + if browse { + if let Some(dir) = Self::pick_folder("Archive folder") { + w.dir = dir.display().to_string(); + w.patients = None; + commit_dir = true; + rescan = true; + } + } + // The window closing counts as leaving the field. + let leaving = close || !open; + let dir_changed = (commit_dir || leaving) && w.dir != self.archive_dir; + if dir_changed { + self.archive_dir = w.dir.clone(); + if !rescan { + w.patients = None; + rescan = true; + } + } + if !leaving { + self.pacs = Some(w); + } + if dir_changed { + self.persist_settings(); + } + if rescan && self.pacs.is_some() { + self.start_pacs_scan(); + } + if import { + if let Some(dir) = Self::pick_folder("Folder to file into the archive") { + self.start_pacs_import(dir); + } + } + if let Some(slot) = upload { + self.start_pacs_upload(slot); + } + if let Some(dir) = remove { + self.start_pacs_remove(dir); + } + if let Some((slot, dir)) = load { + self.start_load(slot, dir); + } + } +} + +/// A study row's label, for the "what is selected" line. +fn st_label(p: &PatientEntry, si: usize) -> String { + match p.studies.get(si) { + Some(st) => format!("{} · {}", p.title(), st.describe()), + None => p.title(), + } +} diff --git a/src/app/panels.rs b/src/app/panels.rs index 93278b4..5a52e21 100644 --- a/src/app/panels.rs +++ b/src/app/panels.rs @@ -1,8 +1,8 @@ //! The side panel and its per-dataset sections. //! //! Each section renders one kind of loaded object -- series, structures, -//! segmentations, dose, plan, planar images, registrations, records -- plus -//! the global registration and simulation controls. +//! segmentations, dose, plan, planar images, registrations, records. The +//! optional registration and simulation sections live in `reg_panel.rs`. use super::*; @@ -383,6 +383,9 @@ impl ViewerApp { me.series_rows(ui, slot, idxs) }); } + for &gi in &node.fourd { + self.fourd_node(ui, slot, pi, si, gi); + } self.structures_section(ui, slot, pi, si, &node.structs); self.segmentation_section(ui, slot, pi, si, &node.segs); self.dose_section(ui, slot, pi, si, &node.doses); @@ -395,6 +398,18 @@ impl ViewerApp { let mut switch_to = None; let mut act: Option = None; let mut rename = None; + let mut fourd: Option = None; + let group_names: Vec<(usize, String)> = self.slots[slot] + .study + .as_ref() + .map(|st| { + st.fourd_groups + .iter() + .enumerate() + .map(|(gi, g)| (gi, g.name.clone())) + .collect() + }) + .unwrap_or_default(); { let Some(study) = self.slots[slot].study.as_ref() else { return; @@ -437,6 +452,27 @@ impl ViewerApp { } } ui.separator(); + ui.menu_button("4D group", |ui| { + for (gi, name) in &group_names { + if ui.button(format!("Add to {name}")).clicked() { + fourd = Some(FourDAction::Add { + slot, + group: *gi, + series: i, + }); + ui.close(); + } + } + if ui.button("New 4D group from this series").clicked() { + fourd = Some(FourDAction::New { slot, series: i }); + ui.close(); + } + if ui.button("Re-detect 4D groups").clicked() { + fourd = Some(FourDAction::Redetect { slot }); + ui.close(); + } + }); + ui.separator(); if ui.button("Remove series").clicked() { act = Some(TreeAction { from: slot, @@ -460,11 +496,278 @@ impl ViewerApp { if rename.is_some() { self.rename_request = rename; } + if fourd.is_some() { + self.fourd_action = fourd; + } + if let Some(i) = switch_to { + self.start_series_switch(slot, i); + } + } + + /// One 4D group node: the ordered members, each row switching the + /// displayed series like an ordinary series row. + fn fourd_node(&mut self, ui: &mut egui::Ui, slot: usize, pi: usize, si: usize, gi: usize) { + let Some(study) = self.slots[slot].study.as_ref() else { + return; + }; + let Some(group) = study.fourd_groups.get(gi) else { + return; + }; + let title = format!("🎞 {}", group.name); + let resolved = group.resolve(&study.series); + // (member index, series index, row label) for every surviving member. + let rows: Vec<(usize, usize, String)> = group + .members + .iter() + .enumerate() + .zip(&resolved) + .filter_map(|((mi, m), r)| { + r.map(|sidx| { + let se = &study.series[sidx]; + let tag = m.role.tag(); + let label = if tag.is_empty() { + format!("{} — {} ({} sl.)", m.label, se.description, se.files.len()) + } else { + format!("{tag} — {} ({} sl.)", se.description, se.files.len()) + }; + (mi, sidx, label) + }) + }) + .collect(); + let n_members = group.members.len(); + let active = study.active_series; + + let mut switch_to = None; + let mut fourd: Option = None; + let mut rename = None; + let resp = Self::wrapped_node(ui, ("fourd", slot, pi, si, gi), true, title, |ui| { + for (mi, sidx, label) in &rows { + let resp = ui.add(egui::Button::selectable(*sidx == active, label).wrap()); + if resp.clicked() && *sidx != active { + switch_to = Some(*sidx); + } + resp.context_menu(|ui| { + if ui.button("⬆ Move up").clicked() { + fourd = Some(FourDAction::Shift { + slot, + group: gi, + member: *mi, + delta: -1, + }); + ui.close(); + } + if ui.button("⬇ Move down").clicked() { + fourd = Some(FourDAction::Shift { + slot, + group: gi, + member: *mi, + delta: 1, + }); + ui.close(); + } + ui.menu_button("Role", |ui| { + for (role, label) in [ + (fourd::Role::Phase, "Phase"), + (fourd::Role::Average, "Average (AVG)"), + (fourd::Role::Mip, "MIP"), + (fourd::Role::MinIp, "MinIP"), + ] { + if ui.button(label).clicked() { + fourd = Some(FourDAction::SetRole { + slot, + group: gi, + member: *mi, + role, + }); + ui.close(); + } + } + }); + ui.separator(); + if ui.button("Remove from group").clicked() { + fourd = Some(FourDAction::RemoveMember { + slot, + group: gi, + member: *mi, + }); + ui.close(); + } + }); + } + if rows.len() < n_members { + ui.weak(format!( + "{} member(s) whose series is gone", + n_members - rows.len() + )); + } + }); + resp.context_menu(|ui| { + if ui.button("✎ Rename group…").clicked() { + rename = Some(RenameTarget::FourD { slot, idx: gi }); + ui.close(); + } + if ui.button("📈 Motion / ITV analysis…").clicked() { + fourd = Some(FourDAction::Analyse { slot, group: gi }); + ui.close(); + } + ui.separator(); + if ui.button("Re-detect 4D groups").clicked() { + fourd = Some(FourDAction::Redetect { slot }); + ui.close(); + } + if ui.button("Dissolve group").clicked() { + fourd = Some(FourDAction::Dissolve { slot, group: gi }); + ui.close(); + } + }); + resp.on_hover_text( + "A 4D sub-study: the phases in temporal order, then the reconstructions.\n\ + Click a phase to display it; right-click for analysis and edits.", + ); + if fourd.is_some() { + self.fourd_action = fourd; + } + if rename.is_some() { + self.rename_request = rename; + } if let Some(i) = switch_to { self.start_series_switch(slot, i); } } + /// Apply a deferred 4D-group edit from the tree's context menus. + pub(super) fn apply_fourd_action(&mut self, act: FourDAction) { + match act { + FourDAction::Analyse { slot, group } => { + self.open_motion_dialog(slot, Some(group)); + return; + } + FourDAction::Redetect { slot } => { + if let Some(study) = self.slots[slot].study.as_mut() { + // An explicit re-detect is the one action that clears + // dissolved tombstones — the user asked for detection. + study.fourd_groups.retain(|g| !g.dissolved); + study.refresh_fourd(); + } + return; + } + _ => {} + } + let slot = match act { + FourDAction::Add { slot, .. } + | FourDAction::New { slot, .. } + | FourDAction::RemoveMember { slot, .. } + | FourDAction::Shift { slot, .. } + | FourDAction::SetRole { slot, .. } + | FourDAction::Dissolve { slot, .. } => slot, + _ => return, + }; + let Some(study) = self.slots[slot].study.as_mut() else { + return; + }; + match act { + FourDAction::Add { group, series, .. } => { + let Some(se) = study.series.get(series) else { + return; + }; + let member = fourd::member_for(se, { + study + .fourd_groups + .get(group) + .map(|g| g.phase_members().len() + 1) + .unwrap_or(1) + }); + if let Some(g) = study.fourd_groups.get_mut(group) { + if !g.members.iter().any(|m| m.series_uid == member.series_uid) { + g.members.push(member); + g.custom = true; + } + } + } + FourDAction::New { series, .. } => { + let Some(se) = study.series.get(series) else { + return; + }; + let member = fourd::member_for(se, 1); + let n = study.fourd_groups.len() + 1; + study.fourd_groups.push(fourd::FourDGroup { + name: format!("4D group {n}"), + study_uid: se.study_uid.clone(), + members: vec![member], + custom: true, + dissolved: false, + }); + } + FourDAction::RemoveMember { group, member, .. } => { + if let Some(g) = study.fourd_groups.get_mut(group) { + if g.members.len() == 1 && member == 0 { + // Removing the last member dissolves the group; the + // member stays inside the tombstone so re-detection + // does not immediately rebuild what was taken apart. + g.dissolved = true; + g.custom = true; + } else if member < g.members.len() { + g.members.remove(member); + g.custom = true; + } + } + } + FourDAction::Shift { + group, + member, + delta, + .. + } => { + if let Some(g) = study.fourd_groups.get_mut(group) { + let to = member as isize + delta; + if to >= 0 && (to as usize) < g.members.len() { + g.members.swap(member, to as usize); + g.custom = true; + } + } + } + FourDAction::SetRole { + group, + member, + role, + .. + } => { + if let Some(m) = study + .fourd_groups + .get_mut(group) + .and_then(|g| g.members.get_mut(member)) + { + m.role = role; + if role != fourd::Role::Phase { + m.label = role.tag().to_string(); + m.percent = None; + } else if m.label.is_empty() + || m.label == "AVG" + || m.label == "MIP" + || m.label == "MinIP" + { + m.label = format!("t{}", member + 1); + } + } + if let Some(g) = study.fourd_groups.get_mut(group) { + g.custom = true; + } + } + FourDAction::Dissolve { group, .. } if group < study.fourd_groups.len() => { + // A custom group leaves nothing behind; an auto-detected one + // leaves a hidden tombstone so re-detection (on the next + // series change) does not resurrect it. *Re-detect 4D + // groups* clears tombstones explicitly. + if study.fourd_groups[group].custom { + study.fourd_groups.remove(group); + } else { + study.fourd_groups[group].dissolved = true; + } + } + _ => {} + } + } + // -- Structure sets and segmentation series ---------------------------- /// Right-click menu of a series node: what image series it is drawn on, @@ -675,6 +978,35 @@ impl ViewerApp { }); } }); + ui.separator(); + if ui + .button(format!("◧ Combine {what}…")) + .on_hover_text( + "Open the structure-algebra window with these as its operands — union, \ + intersection, subtraction, margins", + ) + .clicked() + { + *out = Some(ItemAction::Combine { + from, + items: items.clone(), + }); + ui.close(); + } + if ui + .button(format!("📊 Plot {what} on a DVH…")) + .on_hover_text( + "Open the dose–volume histogram window with these structures against \ + the loaded dose", + ) + .clicked() + { + *out = Some(ItemAction::Dvh { + from, + items: items.clone(), + }); + ui.close(); + } if from.kind == SetKind::Segmentations { ui.separator(); if ui @@ -1050,7 +1382,13 @@ impl ViewerApp { for (tool, hint) in [ ( &BODY_CONTOUR, - "The patient outline: threshold, largest component, fill", + "Outline the patient without the couch, the chair or the \ + immobilisation (EXTERNAL)", + ), + ( + &COMBINE, + "Build one structure out of others: union, intersection, \ + subtraction, margins", ), ( &AUTOSEG, @@ -1238,6 +1576,7 @@ impl ViewerApp { self.create_seg(slot); } match open_tool.map(|t| t.glyph) { + Some(g) if g == COMBINE.glyph => self.open_combine_dialog(slot, Vec::new()), Some(g) if g == BODY_CONTOUR.glyph => self.open_body_dialog(slot), Some(g) if g == AUTOSEG.glyph => self.open_autoseg_dialog(slot), Some(g) if g == PROMPT_SEG.glyph => self.open_segvol_dialog(slot), @@ -1963,6 +2302,8 @@ pub(super) struct StudyNode { segs: Vec, doses: Vec, plans: Vec, + /// 4D groups of this study — indices into `LoadedStudy::fourd_groups`. + fourd: Vec, } /// One patient node: the studies filed under them. @@ -1983,6 +2324,17 @@ pub(super) struct PatientNode { /// there is: a structure set that cannot be reached is worse than one shown /// a level away from where its header claims it lives. pub(super) fn tree_layout(study: &LoadedStudy) -> Vec { + // Series filed under a 4D group render inside that node, not under + // their modality — one series, one place in the tree. + let mut grouped = vec![false; study.series.len()]; + for g in &study.fourd_groups { + if g.dissolved { + continue; + } + for r in g.resolve(&study.series).into_iter().flatten() { + grouped[r] = true; + } + } // Patients and their studies, both in first-seen order. let mut patients: Vec = Vec::new(); for se in &study.series { @@ -2032,10 +2384,14 @@ pub(super) fn tree_layout(study: &LoadedStudy) -> Vec { segs: Vec::new(), doses: Vec::new(), plans: Vec::new(), + fourd: Vec::new(), }); p.studies.last_mut().expect("just pushed") } }; + if grouped[si] { + continue; + } let modality = if se.modality.is_empty() { "Other".to_string() } else { @@ -2047,6 +2403,31 @@ pub(super) fn tree_layout(study: &LoadedStudy) -> Vec { } } + // File each 4D group under its study node (falling back to the study of + // its first surviving series, then to the first study — same rule as + // the RT objects below). + for (gi, g) in study.fourd_groups.iter().enumerate() { + if g.dissolved { + continue; + } + let resolved = g.resolve(&study.series); + let Some(first) = resolved.iter().flatten().next() else { + continue; // nothing left of this group + }; + let fallback = &study.series[*first].study_uid; + let find = |uid: &str| -> Option<(usize, usize)> { + patients.iter().enumerate().find_map(|(pi, p)| { + p.studies + .iter() + .position(|st| st.uid == uid) + .map(|si| (pi, si)) + }) + }; + if let Some((pi, si)) = find(&g.study_uid).or_else(|| find(fallback)) { + patients[pi].studies[si].fourd.push(gi); + } + } + // Where an RT object goes, by the rule in the doc comment above. let series_study = |uid: &str| -> Option { study @@ -2180,6 +2561,8 @@ mod layout_tests { study_uid: study.into(), study_date: "20260827".into(), study_description: String::new(), + series_number: None, + temporal_id: None, files: vec![std::path::PathBuf::from(format!("{uid}.dcm"))], } } @@ -2238,6 +2621,7 @@ mod layout_tests { planar_images: Vec::new(), registrations: Vec::new(), treat_records: Vec::new(), + fourd_groups: Vec::new(), warnings: Vec::new(), default_window: (40.0, 400.0), } diff --git a/src/app/planar.rs b/src/app/planar.rs index d7aaab9..30d0a30 100644 --- a/src/app/planar.rs +++ b/src/app/planar.rs @@ -47,12 +47,13 @@ impl ViewerApp { let title = format!("{}: {} [{}]", SLOT_NAMES[w.slot], img.label, img.modality); let mut open = w.open; - egui::Window::new(title) - .id(egui::Id::new(("planar_win", w.slot, w.idx))) - .open(&mut open) - .default_size([560.0, 640.0]) - .resizable(true) - .show(ctx, |ui| { + detach::tool_window( + ctx, + &format!("planar_{}_{}", w.slot, w.idx), + title, + &mut open, + detach::WinOpts::size(560.0, 640.0).no_scroll(), + |ui| { ui.horizontal(|ui| { ui.label("W/L:"); ui.add(egui::DragValue::new(&mut w.wl.0).speed(4.0).prefix("C ")); @@ -94,7 +95,8 @@ impl ViewerApp { for (k, v) in &img.info { ui.weak(format!("{k}: {v}")); } - }); + }, + ); w.open = open; } windows.retain(|w| w.open); diff --git a/src/app/prompt_seg.rs b/src/app/prompt_seg.rs index b8165d0..0e5d739 100644 --- a/src/app/prompt_seg.rs +++ b/src/app/prompt_seg.rs @@ -19,12 +19,9 @@ //! [`prompt_from_crosshair`] does that mapping and is the only place it //! happens. -use std::path::PathBuf; -use std::sync::Arc; - use crate::models::{self, Engine as ModelsEngine}; use crate::nn::device::DevicePref; -use crate::progress::{Progress, CANCELLED}; +use crate::progress::CANCELLED; use crate::segvol::infer::{self, Config}; use crate::segvol::preprocess::{self, Prepared}; use crate::segvol::prompt::{BBox, Point}; @@ -245,13 +242,13 @@ impl ViewerApp { let mut close = false; let mut browse = false; let mut cancel = false; - egui::Window::new(PROMPT_SEG.title(d.slot)) - .id(egui::Id::new("segvol_window")) - .collapsible(true) - .resizable(false) - .default_width(380.0) - .open(&mut open) - .show(ctx, |ui| { + detach::tool_window( + ctx, + "segvol", + PROMPT_SEG.title(d.slot), + &mut open, + detach::WinOpts::width(380.0).resizable(false), + |ui| { ui.label( "Segments whatever the prompt points at — a box, a click or a structure \ name — with SegVol, re-implemented natively in Rust. For the lesions and \ @@ -369,7 +366,8 @@ impl ViewerApp { ui.separator(); ui.weak(status); } - }); + }, + ); if browse { if let Some(dir) = Self::pick_folder("Model folder") { self.models_dir = dir.display().to_string(); diff --git a/src/app/propagate_win.rs b/src/app/propagate_win.rs index 6fc77ca..e18107e 100644 --- a/src/app/propagate_win.rs +++ b/src/app/propagate_win.rs @@ -297,185 +297,190 @@ impl ViewerApp { .map(|(fixed, _, _)| self.region_choices_for(*fixed)) .unwrap_or_default(); - egui::Window::new(format!( - "⇄ Propagate structures — {} ▶ {}", - SLOT_NAMES[src_slot], SLOT_NAMES[dst_slot] - )) - .id(egui::Id::new("propagate_window")) - .collapsible(true) - .resizable(true) - .default_width(420.0) - .open(&mut open) - .show(ctx, |ui| { - ui.label( - "Carries structures and segmentations from one dataset to the other \ + detach::tool_window( + ctx, + "propagate", + format!( + "⇄ Propagate structures — {} ▶ {}", + SLOT_NAMES[src_slot], SLOT_NAMES[dst_slot] + ), + &mut open, + detach::WinOpts::width(420.0), + |ui| { + ui.label( + "Carries structures and segmentations from one dataset to the other \ through the active registration. Every destination voxel is asked \ where it comes from, so nothing is left with holes.", - ); - ui.separator(); - match ®istered { - None => { - ui.colored_label( - alert_color(ui.visuals()), - "No active registration — run one in the sidebar first.", - ); - } - Some((fixed, method, region)) => { - ui.weak(format!( - "Using: {method}{}", - match region { - Some(r) => format!(" · restricted to {r}"), - None => String::new(), - } - )); - ui.weak(format!( - "Fixed image: dataset {} — the transform is inverted \ + ); + ui.separator(); + match ®istered { + None => { + ui.colored_label( + alert_color(ui.visuals()), + "No active registration — run one in the sidebar first.", + ); + } + Some((fixed, method, region)) => { + ui.weak(format!( + "Using: {method}{}", + match region { + Some(r) => format!(" · restricted to {r}"), + None => String::new(), + } + )); + ui.weak(format!( + "Fixed image: dataset {} — the transform is inverted \ automatically for the other direction.", - SLOT_NAMES[*fixed] - )); + SLOT_NAMES[*fixed] + )); + } } - } - ui.separator(); - - ui.horizontal(|ui| { - ui.label("From"); - ui.selectable_value(&mut d.src_slot, 0, "A ▶ B"); - ui.selectable_value(&mut d.src_slot, 1, "B ▶ A"); - }); + ui.separator(); - ui.horizontal(|ui| { - if ui.small_button("All").clicked() { - set_all = Some(true); - } - if ui.small_button("None").clicked() { - set_all = Some(false); - } - let n = d.structs.iter().filter(|v| **v).count() - + d.segs.iter().filter(|v| **v).count(); - ui.weak(format!("{n} selected")); - }); + ui.horizontal(|ui| { + ui.label("From"); + ui.selectable_value(&mut d.src_slot, 0, "A ▶ B"); + ui.selectable_value(&mut d.src_slot, 1, "B ▶ A"); + }); - egui::ScrollArea::vertical() - .max_height(260.0) - .show(ui, |ui| { - if !struct_rows.is_empty() { - ui.label(egui::RichText::new("Structures").strong()); - for (i, (name, color)) in struct_rows.iter().enumerate() { - ui.horizontal(|ui| { - if let Some(on) = d.structs.get_mut(i) { - ui.checkbox(on, ""); - } - ui.colored_label( - Color32::from_rgb(color[0], color[1], color[2]), - "◼", - ); - ui.label(name); - }); - } - } - if !seg_rows.is_empty() { - ui.add_space(4.0); - ui.label(egui::RichText::new("Segmentations").strong()); - for (i, (name, color, cm3)) in seg_rows.iter().enumerate() { - ui.horizontal(|ui| { - if let Some(on) = d.segs.get_mut(i) { - ui.checkbox(on, ""); - } - ui.colored_label( - Color32::from_rgb(color[0], color[1], color[2]), - "◼", - ); - ui.label(name); - ui.weak(format!("{cm3:.1} cm³")); - }); - } + ui.horizontal(|ui| { + if ui.small_button("All").clicked() { + set_all = Some(true); } - if struct_rows.is_empty() && seg_rows.is_empty() { - ui.weak("This dataset has nothing to propagate."); + if ui.small_button("None").clicked() { + set_all = Some(false); } + let n = d.structs.iter().filter(|v| **v).count() + + d.segs.iter().filter(|v| **v).count(); + ui.weak(format!("{n} selected")); }); - ui.separator(); - egui::CollapsingHeader::new("Refine locally first") - .id_salt("prop_local") - .default_open(false) - .show(ui, |ui| { - ui.label( - "A structure inside a larger one lands where the *larger* one's \ + egui::ScrollArea::vertical() + .max_height(260.0) + .show(ui, |ui| { + if !struct_rows.is_empty() { + ui.label(egui::RichText::new("Structures").strong()); + for (i, (name, color)) in struct_rows.iter().enumerate() { + ui.horizontal(|ui| { + if let Some(on) = d.structs.get_mut(i) { + ui.checkbox(on, ""); + } + ui.colored_label( + Color32::from_rgb(color[0], color[1], color[2]), + "◼", + ); + ui.label(name); + }); + } + } + if !seg_rows.is_empty() { + ui.add_space(4.0); + ui.label(egui::RichText::new("Segmentations").strong()); + for (i, (name, color, cm3)) in seg_rows.iter().enumerate() { + ui.horizontal(|ui| { + if let Some(on) = d.segs.get_mut(i) { + ui.checkbox(on, ""); + } + ui.colored_label( + Color32::from_rgb(color[0], color[1], color[2]), + "◼", + ); + ui.label(name); + ui.weak(format!("{cm3:.1} cm³")); + }); + } + } + if struct_rows.is_empty() && seg_rows.is_empty() { + ui.weak("This dataset has nothing to propagate."); + } + }); + + ui.separator(); + egui::CollapsingHeader::new("Refine locally first") + .id_salt("prop_local") + .default_open(false) + .show(ui, |ui| { + ui.label( + "A structure inside a larger one lands where the *larger* one's \ deformation puts it. Refining the registration on the enclosing \ structure first is what fixes that — and it only changes the \ transform inside that structure.", - ); - ui.horizontal(|ui| { - ui.label("Region"); - let current = local_choices - .iter() - .find(|(c, _)| *c == d.local) - .map(|(_, l)| l.clone()) - .unwrap_or_else(|| "No refinement".into()); - egui::ComboBox::from_id_salt("prop_region") - .selected_text(current) - .width(200.0) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.local, RegRoi::Whole, "No refinement"); - for (choice, label) in &local_choices { - if *choice == RegRoi::Whole { - continue; - } - ui.selectable_value(&mut d.local, *choice, label); - } - }); - }); - if d.local != RegRoi::Whole { + ); ui.horizontal(|ui| { - ui.label("Margin"); - ui.add( - egui::DragValue::new(&mut d.local_margin_mm) - .speed(1.0) - .range(0.0..=60.0) - .suffix(" mm"), - ); + ui.label("Region"); + let current = local_choices + .iter() + .find(|(c, _)| *c == d.local) + .map(|(_, l)| l.clone()) + .unwrap_or_else(|| "No refinement".into()); + egui::ComboBox::from_id_salt("prop_region") + .selected_text(current) + .width(200.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut d.local, + RegRoi::Whole, + "No refinement", + ); + for (choice, label) in &local_choices { + if *choice == RegRoi::Whole { + continue; + } + ui.selectable_value(&mut d.local, *choice, label); + } + }); }); - ui.weak( - "The refinement replaces the active registration, so the \ + if d.local != RegRoi::Whole { + ui.horizontal(|ui| { + ui.label("Margin"); + ui.add( + egui::DragValue::new(&mut d.local_margin_mm) + .speed(1.0) + .range(0.0..=60.0) + .suffix(" mm"), + ); + }); + ui.weak( + "The refinement replaces the active registration, so the \ sidebar reports exactly what the propagation used.", - ); - } - }); - - ui.separator(); - match &self.propagate_job { - Some(job) => cancel = progress_row(ui, &job.progress), - None => { - ui.horizontal(|ui| { - if ui - .add_enabled(registered.is_some(), egui::Button::new("▶ Propagate")) - .on_hover_text( - "Results land as editable segmentations on the other \ - dataset, convertible to RTSTRUCT like any other", - ) - .clicked() - { - run = true; - } - if ui.button("Close").clicked() { - close = true; + ); } }); - } - } - if !d.summary.is_empty() { + ui.separator(); - ui.label(egui::RichText::new("Last run").strong()); - for line in &d.summary { - ui.monospace(line); + match &self.propagate_job { + Some(job) => cancel = progress_row(ui, &job.progress), + None => { + ui.horizontal(|ui| { + if ui + .add_enabled(registered.is_some(), egui::Button::new("▶ Propagate")) + .on_hover_text( + "Results land as editable segmentations on the other \ + dataset, convertible to RTSTRUCT like any other", + ) + .clicked() + { + run = true; + } + if ui.button("Close").clicked() { + close = true; + } + }); + } } - ui.weak( - "A volume change is the deformation's doing: it is exactly what the \ + if !d.summary.is_empty() { + ui.separator(); + ui.label(egui::RichText::new("Last run").strong()); + for line in &d.summary { + ui.monospace(line); + } + ui.weak( + "A volume change is the deformation's doing: it is exactly what the \ Jacobian in the registration panel reports.", - ); - } - }); + ); + } + }, + ); if let Some(v) = set_all { d.structs.iter_mut().for_each(|s| *s = v); diff --git a/src/app/reg_panel.rs b/src/app/reg_panel.rs index cdc6ba0..94257ae 100644 --- a/src/app/reg_panel.rs +++ b/src/app/reg_panel.rs @@ -10,11 +10,7 @@ use anyhow::{anyhow, Result}; use super::*; -use crate::geometry::Vec3; -use crate::registration::{ - analysis, FieldStyle, LandmarkKernel, LandmarkPair, Metric, RegMethod, RegParams, RegionMask, - VectorField, Warp, -}; +use crate::registration::{analysis, LandmarkKernel, RegParams, Warp}; /// What restricts the next registration: everything, or one structure. #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/src/app/rename.rs b/src/app/rename.rs index f85488f..ba44454 100644 --- a/src/app/rename.rs +++ b/src/app/rename.rs @@ -36,6 +36,8 @@ pub(super) enum RenameTarget { Registration { slot: usize, idx: usize }, /// Label of one treatment record. Record { slot: usize, idx: usize }, + /// Name of one 4D group. + FourD { slot: usize, idx: usize }, } impl RenameTarget { @@ -48,7 +50,8 @@ impl RenameTarget { | RenameTarget::Plan { slot, .. } | RenameTarget::Planar { slot, .. } | RenameTarget::Registration { slot, .. } - | RenameTarget::Record { slot, .. } => *slot, + | RenameTarget::Record { slot, .. } + | RenameTarget::FourD { slot, .. } => *slot, RenameTarget::Set(r) => r.slot, RenameTarget::Item { set, .. } => set.slot, } @@ -67,6 +70,7 @@ impl RenameTarget { RenameTarget::Planar { .. } => "planar image", RenameTarget::Registration { .. } => "spatial registration", RenameTarget::Record { .. } => "treatment record", + RenameTarget::FourD { .. } => "4D group", } } @@ -91,6 +95,8 @@ impl RenameTarget { RenameTarget::Planar { .. } => "the image label", RenameTarget::Registration { .. } => "the registration label", RenameTarget::Record { .. } => "the record label", + // A 4D group is an application grouping, not a DICOM object. + RenameTarget::FourD { .. } => "the group's name (application-side only)", } } } @@ -141,6 +147,7 @@ impl ViewerApp { RenameTarget::Planar { idx, .. } => study.planar_images.get(*idx)?.label.clone(), RenameTarget::Registration { idx, .. } => study.registrations.get(*idx)?.label.clone(), RenameTarget::Record { idx, .. } => study.treat_records.get(*idx)?.label.clone(), + RenameTarget::FourD { idx, .. } => study.fourd_groups.get(*idx)?.name.clone(), }) } @@ -231,6 +238,17 @@ impl ViewerApp { RenameTarget::Record { idx, .. } => { set_opt(study.treat_records.get_mut(*idx).map(|r| &mut r.label)) } + RenameTarget::FourD { idx, .. } => { + // A hand-given name is a custom edit: re-detection keeps it. + match study.fourd_groups.get_mut(*idx) { + Some(g) => { + g.name = text.to_string(); + g.custom = true; + true + } + None => false, + } + } } } @@ -328,6 +346,8 @@ mod rename_tests { study_uid: study.into(), study_date: "20260826".into(), study_description: "before".into(), + series_number: None, + temporal_id: None, files: Vec::new(), } } @@ -383,6 +403,7 @@ mod rename_tests { planar_images: Vec::new(), registrations: Vec::new(), treat_records: Vec::new(), + fourd_groups: Vec::new(), warnings: Vec::new(), default_window: (40.0, 400.0), } diff --git a/src/app/seg_engines.rs b/src/app/seg_engines.rs index 4ea9a97..13e0580 100644 --- a/src/app/seg_engines.rs +++ b/src/app/seg_engines.rs @@ -11,9 +11,7 @@ //! model folder, the check that the dataset is still the one the run //! started on, and landing a mask as an editable [`Segmentation`]. -use std::path::PathBuf; - -use crate::models::{self, Engine}; +use crate::models::Engine; use crate::nn::device::DevicePref; use super::*; @@ -52,6 +50,20 @@ pub(super) const BODY_CONTOUR: ToolInfo = ToolInfo { name: "Body contour", verb: "Body-contour", }; +/// The fifth tool, and the only one with no network behind it at all. +pub(super) const COMBINE: ToolInfo = ToolInfo { + glyph: "◧", + name: "Combine structures", + verb: "Combine structures in", +}; +/// The sixth tool: the 4D motion / ITV pipeline. A chart, because what it +/// produces is the motion curves and volumes (and the glyph is covered by +/// egui's bundled emoji fonts, which the quarter-clocks are not). +pub(super) const MOTION: ToolInfo = ToolInfo { + glyph: "📈", + name: "4D motion / ITV", + verb: "Motion-analyse", +}; impl ToolInfo { /// `🤖 Auto-segmentation — dataset A`, the window title. @@ -174,6 +186,20 @@ impl ViewerApp { if let Some(job) = self.body_job.as_ref().filter(|_| self.body_slot == slot) { return Some((&BODY_CONTOUR, &job.progress)); } + if let Some(job) = self + .combine_job + .as_ref() + .filter(|_| self.combine_slot == slot) + { + return Some((&COMBINE, &job.progress)); + } + if let Some(job) = self + .motion_job + .as_ref() + .filter(|_| self.motion_slot == slot) + { + return Some((&MOTION, &job.progress)); + } None } } @@ -202,9 +228,10 @@ pub(super) fn models_root_row(ui: &mut egui::Ui, models_dir: &mut String) -> boo ui.horizontal(|ui| { ui.label("Model folder:"); ui.add(egui::TextEdit::singleline(models_dir).desired_width(220.0)) - .on_hover_text( - "Root folder of all downloaded weights; blank means `models/` next to the program", - ); + .on_hover_text(format!( + "Root folder of all downloaded weights; blank means the default, {}", + models::default_root().display() + )); if ui .button("📁") .on_hover_text("Choose the model folder") @@ -302,15 +329,18 @@ mod tests { assert_eq!(PROMPT_SEG.short_button(), "🧠 Prompt"); assert_eq!(SLICE_PROP.short_button(), "⏩ Propagate"); assert_eq!(BODY_CONTOUR.menu_entry(0), "👤 Body-contour dataset A…"); + assert_eq!(MOTION.short_button(), "📈 Motion"); let mut glyphs = vec![ AUTOSEG.glyph, PROMPT_SEG.glyph, SLICE_PROP.glyph, BODY_CONTOUR.glyph, + COMBINE.glyph, + MOTION.glyph, ]; glyphs.sort(); glyphs.dedup(); - assert_eq!(glyphs.len(), 4, "every tool has its own glyph"); + assert_eq!(glyphs.len(), 6, "every tool has its own glyph"); } #[test] diff --git a/src/app/sets.rs b/src/app/sets.rs index dd4e782..dc83424 100644 --- a/src/app/sets.rs +++ b/src/app/sets.rs @@ -309,6 +309,30 @@ impl ViewerApp { self.rename_request = Some(RenameTarget::Item { set: from, idx }) } ItemAction::ExportSeg { from, items } => self.export_seg_series(from, &items), + ItemAction::Combine { from, items } => { + // The tree already knows which items were ticked; the window + // only has to be told, in the order they were listed. + let seed = items + .iter() + .map(|&idx| combine_win::ItemRef { + kind: from.kind, + set: from.idx, + idx, + }) + .collect(); + self.open_combine_dialog(from.slot, seed); + } + ItemAction::Dvh { from, items } => { + let seed = items + .iter() + .map(|&idx| combine_win::ItemRef { + kind: from.kind, + set: from.idx, + idx, + }) + .collect(); + self.open_dvh_dialog(from.slot, seed); + } ItemAction::Transfer { from, items, diff --git a/src/app/transfer_win.rs b/src/app/transfer_win.rs new file mode 100644 index 0000000..14900cc --- /dev/null +++ b/src/app/transfer_win.rs @@ -0,0 +1,378 @@ +//! *Tools ▶ Transfer by relationship*: place a structure into the other +//! dataset by its spatial relationship to a reference structure. +//! +//! A STAR target defined on one patient's imaging cannot be propagated onto +//! another patient (or another posture) by registration alone when the two +//! datasets share no anatomy-to-anatomy correspondence for it. What travels +//! instead is the *relationship*: the target's offset from the centroid of a +//! reference structure both datasets can segment — typically the heart. The +//! target lands in the destination at the same offset from the destination's +//! reference structure, keeping its shape; deformable adaptation, when +//! wanted, is the propagation tool's job afterwards. + +use crate::motion; + +use super::*; + +/// The window's state. +pub(super) struct TransferDialog { + /// Dataset the target comes from; it lands on the other one. + pub src_slot: usize, + /// Candidate index of the target in the source dataset. + pub target: Option, + /// Candidate index of the reference structure in the source dataset. + pub src_ref: Option, + /// Candidate index of the reference structure in the destination. + pub dst_ref: Option, + pub status: Option, +} + +impl ViewerApp { + pub(super) fn open_transfer_dialog(&mut self, src_slot: usize) { + let mut d = TransferDialog { + src_slot, + target: None, + src_ref: None, + dst_ref: None, + status: None, + }; + // Pre-pick reference structures by the obvious name. + let guess = |cands: &[(super::combine_win::ItemRef, String)]| { + cands.iter().position(|(_, l)| { + let l = l.to_lowercase(); + l.contains("heart") || l.contains("herz") + }) + }; + d.src_ref = guess(&self.combine_candidates(src_slot)); + d.dst_ref = guess(&self.combine_candidates(1 - src_slot)); + self.transfer_dialog = Some(d); + } + + /// Carry the target across, synchronously — a translation and one + /// nearest-neighbour resampling over the target's bounding box. + fn transfer_now(&mut self) { + let Some(d) = &self.transfer_dialog else { + return; + }; + let (src, dst) = (d.src_slot, 1 - d.src_slot); + let pick = |slot: usize, sel: Option| { + sel.and_then(|i| self.combine_candidates(slot).get(i).cloned()) + }; + let (Some((it, _)), Some((ir, _)), Some((id_, _))) = ( + pick(src, d.target), + pick(src, d.src_ref), + pick(dst, d.dst_ref), + ) else { + if let Some(d) = &mut self.transfer_dialog { + d.status = Some("Pick the target and both reference structures first.".into()); + } + return; + }; + let (Some((tm, tg, tname, tcolor)), Some((rm, rg, rname, _)), Some((dm, dg, dname, _))) = ( + self.item_mask_grid(src, it), + self.item_mask_grid(src, ir), + self.item_mask_grid(dst, id_), + ) else { + if let Some(d) = &mut self.transfer_dialog { + d.status = Some("One of the structures is gone or empty.".into()); + } + return; + }; + let (Some(c_target), Some(c_src), Some(c_dst)) = ( + motion::centroid_mm(&tm, &tg), + motion::centroid_mm(&rm, &rg), + motion::centroid_mm(&dm, &dg), + ) else { + if let Some(d) = &mut self.transfer_dialog { + d.status = Some("One of the structures has no voxels.".into()); + } + return; + }; + let delta = c_dst - c_src; + + // The destination lattice is the displayed volume of the other + // dataset — that is where a new segmentation is editable. + let Some(study) = self.slots[dst].study.as_ref() else { + return; + }; + let out_grid = study.volume.grid(); + let mask = translate_mask(&tm, &tg, &out_grid, delta); + if mask.iter().all(|&v| v == 0) { + if let Some(dlg) = &mut self.transfer_dialog { + dlg.status = Some(format!( + "'{tname}' lands outside dataset {}'s displayed volume — nothing to store.", + SLOT_NAMES[dst] + )); + } + return; + } + let placed_cm3 = motion::volume_cm3(&mask, &out_grid); + let name = format!("{tname} @ {dname}"); + let dims = out_grid.dims; + self.add_colored_segmentation(dst, name.clone(), tcolor, dims, &mask); + if let Some(dlg) = &mut self.transfer_dialog { + dlg.status = Some(format!( + "'{name}' stored in dataset {} — offset from {rname}: RL {:+.1} · AP {:+.1} · \ + SI {:+.1} mm, {placed_cm3:.2} cm³.", + SLOT_NAMES[dst], + c_target.x - c_src.x, + c_target.y - c_src.y, + c_target.z - c_src.z, + )); + } + } + + pub(super) fn transfer_window(&mut self, ctx: &egui::Context) { + let Some(d) = &self.transfer_dialog else { + return; + }; + let src = d.src_slot; + let dst = 1 - src; + if self.slots[src].study.is_none() || self.slots[dst].study.is_none() { + self.transfer_dialog = None; + return; + } + let src_cands: Vec = self + .combine_candidates(src) + .into_iter() + .map(|(_, l)| l) + .collect(); + let dst_cands: Vec = self + .combine_candidates(dst) + .into_iter() + .map(|(_, l)| l) + .collect(); + let mut run = false; + let mut close = false; + let mut swap = false; + let mut open = true; + let d = self.transfer_dialog.as_mut().expect("checked above"); + detach::tool_window( + ctx, + "transfer", + "◎ Transfer by relationship", + &mut open, + detach::WinOpts::default().resizable(false), + |ui| { + ui.label(format!( + "Place a structure of dataset {} into dataset {} at the same offset \ + from a reference structure (e.g. the heart) — the target–reference \ + relationship travels, not the image registration.", + SLOT_NAMES[src], SLOT_NAMES[dst] + )); + ui.add_space(4.0); + let combo = |ui: &mut egui::Ui, + label: &str, + item: &mut Option, + list: &[String], + salt: &str| { + ui.horizontal(|ui| { + ui.label(label); + let sel = item + .and_then(|i| list.get(i).cloned()) + .unwrap_or_else(|| "(pick)".into()); + egui::ComboBox::from_id_salt(salt.to_string()) + .width(260.0) + .selected_text(sel) + .show_ui(ui, |ui| { + for (i, l) in list.iter().enumerate() { + ui.selectable_value(item, Some(i), l); + } + }); + }); + }; + combo( + ui, + &format!("Target ({}):", SLOT_NAMES[src]), + &mut d.target, + &src_cands, + "tr_target", + ); + combo( + ui, + &format!("Reference in {}:", SLOT_NAMES[src]), + &mut d.src_ref, + &src_cands, + "tr_src_ref", + ); + combo( + ui, + &format!("Reference in {}:", SLOT_NAMES[dst]), + &mut d.dst_ref, + &dst_cands, + "tr_dst_ref", + ); + if ui + .button(format!("Swap direction (to dataset {})", SLOT_NAMES[src])) + .clicked() + { + swap = true; + } + ui.add_space(4.0); + if let Some(status) = &d.status { + ui.label(status.clone()); + } + ui.horizontal(|ui| { + if ui.button("▶ Transfer").clicked() { + run = true; + } + if ui.button("Close").clicked() { + close = true; + } + }); + }, + ); + if swap { + if let Some(d) = &mut self.transfer_dialog { + d.src_slot = 1 - d.src_slot; + d.target = None; + d.src_ref = None; + d.dst_ref = None; + d.status = None; + } + if let Some(slot) = self.transfer_dialog.as_ref().map(|d| d.src_slot) { + let guess = |cands: Vec<(super::combine_win::ItemRef, String)>| { + cands.iter().position(|(_, l)| { + let l = l.to_lowercase(); + l.contains("heart") || l.contains("herz") + }) + }; + let s = guess(self.combine_candidates(slot)); + let t = guess(self.combine_candidates(1 - slot)); + if let Some(d) = &mut self.transfer_dialog { + d.src_ref = s; + d.dst_ref = t; + } + } + } + if run { + self.transfer_now(); + } + if close || !open { + self.transfer_dialog = None; + } + } +} + +/// Resample `mask` (on `from`) onto `to`, shifted by `delta` in patient +/// coordinates: `out(p) = mask(p − delta)`. Nearest neighbour, restricted +/// to the translated bounding box of the source mask. +fn translate_mask( + mask: &[u8], + from: &crate::volume::Grid, + to: &crate::volume::Grid, + delta: crate::geometry::Vec3, +) -> Vec { + let [nx, ny, nz] = to.dims; + let mut out = vec![0u8; nx * ny * nz]; + // Bounding box of the source mask, in source voxels. + let [sx, sy, sz] = from.dims; + let (mut lo, mut hi) = ([usize::MAX; 3], [0usize; 3]); + for k in 0..sz { + for j in 0..sy { + for i in 0..sx { + if mask[k * sx * sy + j * sx + i] != 0 { + let v = [i, j, k]; + for a in 0..3 { + lo[a] = lo[a].min(v[a]); + hi[a] = hi[a].max(v[a]); + } + } + } + } + } + if lo[0] == usize::MAX { + return out; + } + // The eight translated corners, in destination voxels, give the + // destination box to fill (padded a voxel for rounding). + let (mut dlo, mut dhi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]); + for &ci in &[lo[0], hi[0]] { + for &cj in &[lo[1], hi[1]] { + for &ck in &[lo[2], hi[2]] { + let p = from.voxel_to_patient(ci as f64, cj as f64, ck as f64) + delta; + let v = to.patient_to_voxel(p); + for a in 0..3 { + dlo[a] = dlo[a].min(v[a]); + dhi[a] = dhi[a].max(v[a]); + } + } + } + } + let clamp = |v: f64, n: usize| (v.max(0.0) as usize).min(n.saturating_sub(1)); + let (blo, bhi) = ( + [ + clamp(dlo[0].floor() - 1.0, nx), + clamp(dlo[1].floor() - 1.0, ny), + clamp(dlo[2].floor() - 1.0, nz), + ], + [ + clamp(dhi[0].ceil() + 1.0, nx), + clamp(dhi[1].ceil() + 1.0, ny), + clamp(dhi[2].ceil() + 1.0, nz), + ], + ); + for k in blo[2]..=bhi[2] { + for j in blo[1]..=bhi[1] { + for i in blo[0]..=bhi[0] { + let p = to.voxel_to_patient(i as f64, j as f64, k as f64) - delta; + let v = from.patient_to_voxel(p); + let (si, sj, sk) = (v[0].round(), v[1].round(), v[2].round()); + if si < 0.0 || sj < 0.0 || sk < 0.0 { + continue; + } + let (si, sj, sk) = (si as usize, sj as usize, sk as usize); + if si >= sx || sj >= sy || sk >= sz { + continue; + } + if mask[sk * sx * sy + sj * sx + si] != 0 { + out[k * nx * ny + j * nx + i] = 1; + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::Vec3; + use crate::volume::Grid; + + fn grid(origin: Vec3) -> Grid { + Grid { + dims: [20, 20, 10], + spacing: [1.0, 1.0, 2.0], + origin, + row_dir: Vec3::new(1.0, 0.0, 0.0), + col_dir: Vec3::new(0.0, 1.0, 0.0), + normal: Vec3::new(0.0, 0.0, 1.0), + frame_of_reference_uid: String::new(), + } + } + + #[test] + fn a_translated_mask_lands_at_the_offset_position() { + let g1 = grid(Vec3::ZERO); + let g2 = grid(Vec3::new(2.0, 0.0, 0.0)); // destination shifted lattice + let mut m = vec![0u8; 20 * 20 * 10]; + // A 3×3×1 block around voxel (5, 5, 5). + for j in 4..7 { + for i in 4..7 { + m[5 * 400 + j * 20 + i] = 1; + } + } + let delta = Vec3::new(6.0, -2.0, 0.0); + let out = translate_mask(&m, &g1, &g2, delta); + let c_in = crate::motion::centroid_mm(&m, &g1).unwrap(); + let c_out = crate::motion::centroid_mm(&out, &g2).unwrap(); + let moved = c_out - c_in; + assert!((moved - delta).length() < 0.75, "moved {moved:?}"); + assert_eq!( + out.iter().filter(|&&v| v != 0).count(), + 9, + "the block keeps its size" + ); + } +} diff --git a/src/app/tree.rs b/src/app/tree.rs index aadb2fd..7025874 100644 --- a/src/app/tree.rs +++ b/src/app/tree.rs @@ -213,6 +213,7 @@ impl ViewerApp { } else { Vec::new() }, + fourd_groups: study.fourd_groups.clone(), warnings: Vec::new(), default_window: study.default_window, } @@ -402,6 +403,11 @@ impl ViewerApp { self.tree_clear_slot(slot); return; } + if let Some(st) = self.slots[slot].study.as_mut() { + // Groups follow the series they reference; removed series drop + // out and a group left empty disappears. + st.refresh_fourd(); + } if let Some(i) = reload { self.start_series_switch(slot, i); } @@ -427,6 +433,8 @@ mod tree_tests { study_uid: study.into(), study_date: "20260818".into(), study_description: String::new(), + series_number: None, + temporal_id: None, files: vec![std::path::PathBuf::from(format!("{uid}.dcm"))], } } @@ -506,6 +514,7 @@ mod tree_tests { planar_images: Vec::new(), registrations: Vec::new(), treat_records: Vec::new(), + fourd_groups: Vec::new(), warnings: Vec::new(), default_window: (40.0, 400.0), } diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..84b156f --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,520 @@ +//! The local patient archive — the application's own store of DICOM studies. +//! +//! A small PACS in the sense that matters day to day: every study ever +//! imported is filed under its patient, listed without opening a single +//! DICOM file, loaded into a viewer dataset on demand, and given back the +//! contours and segmentations drawn on it. +//! +//! ## Layout +//! +//! ```text +//! / +//! / PATIENT.txt name, id +//! / STUDY.txt uid, date, description, modalities, files +//! .dcm +//! ``` +//! +//! Folder names are the DICOM UIDs, which are digits and dots and therefore +//! already safe; only the patient folder is derived from free text and needs +//! sanitizing. +//! +//! ## Why the sidecars +//! +//! Listing the archive must stay instant however large it grows, and reading +//! headers out of ten thousand files is not instant. Each study folder +//! therefore carries a `STUDY.txt` written when anything is filed into it, +//! in the same `key = value` shape as the settings file. A folder that +//! arrived without one — copied in by hand — gets it rebuilt from the +//! headers once, and is fast from then on. +//! +//! The sidecars are a cache, never the truth: the `.dcm` files are, and the +//! archive can always be rebuilt from them. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use dicom_dictionary_std::tags; +use dicom_object::OpenFileOptions; + +use crate::loader::str_of; +use crate::progress::Progress; +use crate::settings; + +const PATIENT_FILE: &str = "PATIENT.txt"; +const STUDY_FILE: &str = "STUDY.txt"; + +/// One study of the archive, as its sidecar describes it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StudyEntry { + pub study_uid: String, + pub date: String, + pub description: String, + /// Every modality present in the study, sorted. + pub modalities: Vec, + pub files: usize, + pub dir: PathBuf, +} + +impl StudyEntry { + /// `20260827 — Planning · CT, RTSTRUCT · 214 files`. + pub fn describe(&self) -> String { + format!( + "{}{} · {} · {} file{}", + if self.date.is_empty() { + "undated".into() + } else { + self.date.clone() + }, + if self.description.is_empty() { + String::new() + } else { + format!(" — {}", self.description) + }, + if self.modalities.is_empty() { + "?".into() + } else { + self.modalities.join(", ") + }, + self.files, + if self.files == 1 { "" } else { "s" } + ) + } +} + +/// One patient of the archive and the studies filed under them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PatientEntry { + pub name: String, + pub id: String, + pub dir: PathBuf, + pub studies: Vec, +} + +impl PatientEntry { + /// What the list calls them: `Doe John (P0001)`. + pub fn title(&self) -> String { + let name = self.name.replace('^', " "); + match (name.is_empty(), self.id.is_empty()) { + (true, true) => "Unknown patient".into(), + (true, false) => format!("Patient {}", self.id), + (false, true) => name, + (false, false) => format!("{name} ({})", self.id), + } + } + + pub fn files(&self) -> usize { + self.studies.iter().map(|s| s.files).sum() + } +} + +/// What an import did, for the line the window reports afterwards. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImportSummary { + pub stored: usize, + /// Files already in the archive under the same SOP Instance UID. + pub duplicates: usize, + /// Files that were not readable as DICOM. + pub skipped: usize, + pub patients: usize, + pub studies: usize, +} + +impl ImportSummary { + pub fn describe(&self) -> String { + format!( + "{} file(s) filed under {} patient(s) / {} study(ies){}{}", + self.stored, + self.patients, + self.studies, + if self.duplicates > 0 { + format!(", {} already there", self.duplicates) + } else { + String::new() + }, + if self.skipped > 0 { + format!(", {} not DICOM", self.skipped) + } else { + String::new() + } + ) + } +} + +/// Default archive root: `/archive`. +pub fn default_root() -> PathBuf { + settings::data_dir().join("archive") +} + +/// The root a settings value names, falling back to [`default_root`] when it +/// is blank. +pub fn root_from_setting(setting: &str) -> PathBuf { + let t = setting.trim(); + if t.is_empty() { + default_root() + } else { + PathBuf::from(t) + } +} + +/// Keep a free-text identifier usable as a folder name. +/// +/// Patient identifiers arrive as whatever the acquiring system wrote — +/// slashes, colons, trailing spaces, non-ASCII. Anything outside a +/// conservative set becomes `_`, which can map two identifiers onto one +/// folder; that merges two patients who already share an identifier, which +/// is the correct reading, and is the reason the folder name is never the +/// authority — `PATIENT.txt` is. +fn sanitize(s: &str) -> String { + let out: String = s + .trim() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + let out = out.trim_matches('_').to_string(); + if out.is_empty() { + "unknown".into() + } else { + out.chars().take(96).collect() + } +} + +/// Read a `key = value` sidecar. +fn read_sidecar(path: &Path) -> Vec<(String, String)> { + let Ok(text) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + text.lines() + .filter_map(|l| l.split_once('=')) + .map(|(k, v)| (k.trim().to_lowercase(), v.trim().to_string())) + .collect() +} + +fn field(pairs: &[(String, String)], key: &str) -> String { + pairs + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .unwrap_or_default() +} + +/// The application's store of DICOM studies, rooted at one folder. +pub struct Archive { + root: PathBuf, +} + +impl Archive { + pub fn new(root: impl Into) -> Archive { + Archive { root: root.into() } + } + + /// Every patient in the archive, with their studies, in name order. + /// + /// Reads sidecars only; a study folder without one has it rebuilt from + /// the headers first, so a folder dropped in by hand costs that once. + pub fn scan(&self) -> Result> { + let mut out = Vec::new(); + let Ok(dirs) = std::fs::read_dir(&self.root) else { + // A root that does not exist yet is an empty archive, not a + // failure — it is created on the first import. + return Ok(out); + }; + for pd in dirs.filter_map(|e| e.ok()) { + if !pd.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let pdir = pd.path(); + let p = read_sidecar(&pdir.join(PATIENT_FILE)); + let mut patient = PatientEntry { + name: field(&p, "name"), + id: field(&p, "id"), + dir: pdir.clone(), + studies: Vec::new(), + }; + let Ok(sdirs) = std::fs::read_dir(&pdir) else { + continue; + }; + for sd in sdirs.filter_map(|e| e.ok()) { + if !sd.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let sdir = sd.path(); + let card = sdir.join(STUDY_FILE); + if !card.exists() { + let _ = self.rebuild_sidecars(&sdir); + } + let s = read_sidecar(&card); + patient.studies.push(StudyEntry { + study_uid: field(&s, "uid"), + date: field(&s, "date"), + description: field(&s, "description"), + modalities: field(&s, "modalities") + .split(',') + .map(|m| m.trim().to_string()) + .filter(|m| !m.is_empty()) + .collect(), + files: field(&s, "files").parse().unwrap_or(0), + dir: sdir, + }); + } + if patient.studies.is_empty() { + continue; + } + // Newest study first — what one is normally after. + patient.studies.sort_by(|a, b| b.date.cmp(&a.date)); + out.push(patient); + } + out.sort_by_key(|a| a.title().to_lowercase()); + Ok(out) + } + + /// Rebuild a study folder's sidecar (and its patient's) from the headers + /// of the files in it. + fn rebuild_sidecars(&self, study_dir: &Path) -> Result<()> { + let mut modalities: BTreeSet = BTreeSet::new(); + let mut files = 0usize; + let (mut uid, mut date, mut desc) = (String::new(), String::new(), String::new()); + let (mut pname, mut pid) = (String::new(), String::new()); + for f in std::fs::read_dir(study_dir)?.filter_map(|e| e.ok()) { + if !f.file_type().map(|t| t.is_file()).unwrap_or(false) { + continue; + } + let Ok(obj) = OpenFileOptions::new() + .read_until(tags::PIXEL_DATA) + .open_file(f.path()) + else { + continue; + }; + files += 1; + if let Some(m) = str_of(&obj, tags::MODALITY) { + modalities.insert(m); + } + if uid.is_empty() { + uid = str_of(&obj, tags::STUDY_INSTANCE_UID).unwrap_or_default(); + date = str_of(&obj, tags::STUDY_DATE).unwrap_or_default(); + desc = str_of(&obj, tags::STUDY_DESCRIPTION).unwrap_or_default(); + pname = str_of(&obj, tags::PATIENT_NAME).unwrap_or_default(); + pid = str_of(&obj, tags::PATIENT_ID).unwrap_or_default(); + } + } + if files == 0 { + return Ok(()); + } + write_study_card( + study_dir, + &uid, + &date, + &desc, + &modalities.iter().cloned().collect::>(), + files, + )?; + if let Some(pdir) = study_dir.parent() { + let card = pdir.join(PATIENT_FILE); + if !card.exists() { + write_patient_card(pdir, &pname, &pid)?; + } + } + Ok(()) + } + + /// Where a patient's study lives, creating neither. + fn study_dir(&self, patient_key: &str, study_uid: &str) -> PathBuf { + self.root + .join(sanitize(patient_key)) + .join(sanitize(study_uid)) + } + + /// File every DICOM file under `src` into the archive. + /// + /// Files are copied, never moved: importing must not take the source + /// folder apart. A file whose SOP Instance UID is already stored under + /// the same study is counted as a duplicate and left alone, so importing + /// the same folder twice is a no-op rather than a second copy. + pub fn import(&self, src: &Path, progress: &Progress) -> Result { + progress.set("Scanning the folder…"); + let files: Vec = walkdir::WalkDir::new(src) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| e.into_path()) + .collect(); + let mut sum = ImportSummary::default(); + let mut touched: BTreeSet = BTreeSet::new(); + let mut patients: BTreeSet = BTreeSet::new(); + for (n, path) in files.iter().enumerate() { + if n % 25 == 0 { + progress.set(format!("Filing {}/{}…", n + 1, files.len())); + } + let Ok(obj) = OpenFileOptions::new() + .read_until(tags::PIXEL_DATA) + .open_file(path) + else { + sum.skipped += 1; + continue; + }; + let sop = str_of(&obj, tags::SOP_INSTANCE_UID).unwrap_or_default(); + let study_uid = str_of(&obj, tags::STUDY_INSTANCE_UID).unwrap_or_default(); + let pid = str_of(&obj, tags::PATIENT_ID).unwrap_or_default(); + let pname = str_of(&obj, tags::PATIENT_NAME).unwrap_or_default(); + if sop.is_empty() || study_uid.is_empty() { + sum.skipped += 1; + continue; + } + let key = if pid.is_empty() { + pname.clone() + } else { + pid.clone() + }; + let sdir = self.study_dir(&key, &study_uid); + let dest = sdir.join(format!("{}.dcm", sanitize(&sop))); + if dest.exists() { + sum.duplicates += 1; + touched.insert(sdir); + continue; + } + std::fs::create_dir_all(&sdir).with_context(|| format!("create {}", sdir.display()))?; + std::fs::copy(path, &dest) + .with_context(|| format!("copy {} into the archive", path.display()))?; + sum.stored += 1; + if let Some(pdir) = sdir.parent() { + if !pdir.join(PATIENT_FILE).exists() { + write_patient_card(pdir, &pname, &pid)?; + } + patients.insert(pdir.to_path_buf()); + } + touched.insert(sdir); + } + // The sidecars are rebuilt once per touched study rather than per + // file — the counts and modality list are only right at the end. + progress.set("Updating the archive index…"); + for sdir in &touched { + let _ = self.rebuild_sidecars(sdir); + } + sum.studies = touched.len(); + sum.patients = patients.len(); + progress.set("done"); + Ok(sum) + } + + /// Delete a study folder, or a whole patient. + /// + /// Refuses anything that is not inside the archive root, because the + /// path comes from a listing that a stale rescan could have made wrong. + pub fn remove(&self, dir: &Path) -> Result<()> { + let root = self + .root + .canonicalize() + .unwrap_or_else(|_| self.root.clone()); + let target = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + anyhow::ensure!( + target.starts_with(&root) && target != root, + "{} is not inside the archive", + dir.display() + ); + std::fs::remove_dir_all(&target).with_context(|| format!("remove {}", target.display())) + } +} + +fn write_patient_card(dir: &Path, name: &str, id: &str) -> Result<()> { + std::fs::create_dir_all(dir)?; + std::fs::write( + dir.join(PATIENT_FILE), + format!("name = {name}\nid = {id}\n"), + ) + .with_context(|| format!("write {}", dir.join(PATIENT_FILE).display())) +} + +fn write_study_card( + dir: &Path, + uid: &str, + date: &str, + description: &str, + modalities: &[String], + files: usize, +) -> Result<()> { + std::fs::write( + dir.join(STUDY_FILE), + format!( + "uid = {uid}\ndate = {date}\ndescription = {description}\n\ + modalities = {}\nfiles = {files}\n", + modalities.join(",") + ), + ) + .with_context(|| format!("write {}", dir.join(STUDY_FILE).display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folder_names_survive_whatever_an_acquiring_system_wrote() { + assert_eq!(sanitize("P0001"), "P0001"); + assert_eq!(sanitize(" Doe^John / 3 "), "Doe_John___3"); + assert_eq!(sanitize("1.2.840.113619.2"), "1.2.840.113619.2"); + assert_eq!(sanitize(""), "unknown"); + assert_eq!(sanitize("___"), "unknown", "nothing usable is left"); + assert_eq!(sanitize(&"x".repeat(200)).len(), 96, "capped"); + } + + #[test] + fn a_missing_root_is_an_empty_archive_rather_than_an_error() { + let dir = std::env::temp_dir().join("rds_archive_missing_root"); + let _ = std::fs::remove_dir_all(&dir); + let a = Archive::new(&dir); + assert_eq!(a.scan().expect("scan succeeds").len(), 0); + } + + #[test] + fn a_study_reads_back_from_its_sidecar() { + let root = std::env::temp_dir().join("rds_archive_sidecar"); + let _ = std::fs::remove_dir_all(&root); + let sdir = root.join("P1").join("1.2.3"); + std::fs::create_dir_all(&sdir).unwrap(); + write_patient_card(sdir.parent().unwrap(), "Doe^John", "P1").unwrap(); + write_study_card( + &sdir, + "1.2.3", + "20260827", + "Planning", + &["CT".into(), "RTSTRUCT".into()], + 214, + ) + .unwrap(); + + let found = Archive::new(&root).scan().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].title(), "Doe John (P1)"); + assert_eq!(found[0].files(), 214); + let st = &found[0].studies[0]; + assert_eq!(st.study_uid, "1.2.3"); + assert_eq!(st.modalities, vec!["CT", "RTSTRUCT"]); + assert!(st + .describe() + .starts_with("20260827 — Planning · CT, RTSTRUCT · 214 files")); + let _ = std::fs::remove_dir_all(&root); + } + + /// A path handed back by a stale listing must never delete anything + /// outside the archive. + #[test] + fn remove_refuses_to_step_outside_the_archive() { + let root = std::env::temp_dir().join("rds_archive_guard"); + std::fs::create_dir_all(root.join("P1")).unwrap(); + let a = Archive::new(&root); + assert!(a.remove(&root).is_err(), "the root itself is refused"); + assert!( + a.remove(&std::env::temp_dir()).is_err(), + "a folder outside is refused" + ); + assert!(a.remove(&root.join("P1")).is_ok()); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src/autoseg/mod.rs b/src/autoseg/mod.rs index af9fd4a..6b0413f 100644 --- a/src/autoseg/mod.rs +++ b/src/autoseg/mod.rs @@ -156,19 +156,28 @@ impl infer::InferHooks for Hooks<'_> { /// they do with the answer, not in how a checkpoint is fetched, converted, /// resampled onto, tiled over or mapped back from. /// -/// `label` names the run in progress messages. +/// `label` names the run in progress messages, and `window` is the slice of +/// the overall progress bar this run owns — `(0.0, 1.0)` for the whole of it. +/// [`Progress::set_phase`] is absolute rather than nested, so a caller that +/// has its own work to do afterwards has to say so here; otherwise the bar +/// reaches 100 % and then jumps backwards. pub fn run_specs( volume: &Volume, specs: &[ModelSpec], label: &'static str, device: DevicePref, models_dir: &Path, + window: (f32, f32), progress: &Progress, ) -> Result<(Vec, String)> { if specs.is_empty() { bail!("no sub-models selected"); } let n_models = specs.len(); + let (base, span) = window; + // Every phase below is expressed in this run's own 0..1 and mapped onto + // the window the caller gave. + let phase = |p: &Progress, at: f32, len: f32| p.set_phase(base + span * at, span * len); // Progress budget: 15% download/convert/load, 5% preprocess, // 75% inference, 5% postprocess. @@ -177,7 +186,7 @@ pub fn run_specs( // ---- load models (download + convert on first use) ------------------- let mut models = Vec::with_capacity(n_models); for (i, spec) in specs.iter().enumerate() { - progress.set_phase(i as f32 * dl_span, dl_span); + phase(progress, i as f32 * dl_span, dl_span); let m = weights::ensure_model(spec, models_dir, progress)?; if progress.cancelled() { bail!(CANCELLED); @@ -202,7 +211,7 @@ pub fn run_specs( progress.set_device(&device_desc); // ---- preprocess ------------------------------------------------------ - progress.set_phase(0.15, 0.05); + phase(progress, 0.15, 0.05); progress.report( 0.0, &format!( @@ -224,7 +233,7 @@ pub fn run_specs( let mut global = vec![0u8; vol_model.len()]; let infer_span = 0.75 / n_models as f32; for (mi, model) in models.iter().enumerate() { - progress.set_phase(0.2 + mi as f32 * infer_span, infer_span); + phase(progress, 0.2 + mi as f32 * infer_span, infer_span); // A z-score model normalizes against this image, so its constants // are only knowable now, with the resampled volume in hand. let mut cfg = model.config.clone(); @@ -281,7 +290,7 @@ pub fn run_specs( } // ---- back-map to the CT grid ---------------------------------------- - progress.set_phase(0.95, 0.05); + phase(progress, 0.95, 0.05); progress.report(0.0, "Mapping labels back to the CT grid…"); let labels = preprocess::labels_to_volume_grid(&global, &map, volume); Ok((labels, device_desc)) @@ -308,6 +317,7 @@ pub fn run( variant.label(), device, models_dir, + (0.0, 1.0), progress, )?; diff --git a/src/autoseg/weights.rs b/src/autoseg/weights.rs index f9263b4..bd8b481 100644 --- a/src/autoseg/weights.rs +++ b/src/autoseg/weights.rs @@ -135,11 +135,6 @@ pub fn all_specs() -> Vec { v } -/// The published model a cache key names, if any. -pub fn spec_by_key(key: &str) -> Option { - all_specs().into_iter().find(|s| s.key == key) -} - /// A ready-to-run model: architecture config + named weight tensors. pub struct LoadedModel { pub spec: ModelSpec, diff --git a/src/bodymask.rs b/src/bodymask.rs index 7c23d34..b537a5d 100644 --- a/src/bodymask.rs +++ b/src/bodymask.rs @@ -166,8 +166,10 @@ pub struct BodyParams { /// itself a candidate, and since it repeats slice after slice it is /// then indistinguishable from a couch: the whole ribcage goes. pub device_thin_mm: f64, - /// Run the extruded-equipment test. Off by default in the - /// model-assisted method, where the network has already answered. + /// Run the extruded-equipment test. On in both methods: it has little + /// left to do once a network has answered, but the guide is used + /// *dilated*, and a margin that generous can pull a touching rail back + /// in. pub remove_devices: bool, /// How far a device footprint has to repeat to count as extruded. pub persist_window_mm: f64, @@ -233,7 +235,8 @@ impl BodyParams { } } -/// One piece of the finished contour, for the results line. +/// One piece of the finished contour — two legs are two pieces — with its +/// own size, so the status line can say how big each one is. #[derive(Clone, Debug)] pub struct Piece { pub voxels: u64, @@ -245,7 +248,6 @@ pub struct BodyResult { /// 0/1 per voxel, in [`Volume::data`] index order. Omitted from the /// `Debug` output, which is otherwise 35 MB of ones and zeros. pub mask: Vec, - pub dims: [usize; 3], pub voxels: u64, pub cm3: f64, /// The separate bodies kept — two legs are two pieces, and saying so is @@ -275,7 +277,6 @@ impl std::fmt::Debug for BodyResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BodyResult") .field("mask", &format_args!("<{} voxels>", self.mask.len())) - .field("dims", &self.dims) .field("voxels", &self.voxels) .field("cm3", &self.cm3) .field("pieces", &self.pieces) @@ -314,6 +315,19 @@ pub fn contour_body( let t0 = std::time::Instant::now(); let dims = volume.dims; let spacing = volume.spacing; + // Everything below measures in millimetres, so a series that declares a + // nonsensical geometry has to be refused here rather than producing a + // distance transform full of NaNs several minutes later. + if spacing.iter().any(|s| !s.is_finite() || *s <= 1e-4) { + bail!( + "this series declares a voxel spacing of {:?} mm, which no measurement in \ + millimetres can be made from", + spacing + ); + } + if dims.contains(&0) { + bail!("this series has no voxels"); + } let voxel_cm3 = spacing[0] * spacing[1] * spacing[2] / 1000.0; // ---- 1. foreground --------------------------------------------------- @@ -328,7 +342,6 @@ pub fn contour_body( let mut device = String::new(); let mut guide: Option> = None; if params.method == Method::ModelAssisted { - progress.set_phase(0.0, 0.70); let spec = params.model.spec(); let (labels, dev) = crate::autoseg::run_specs( volume, @@ -336,6 +349,7 @@ pub fn contour_body( "body outline", params.device, models_dir, + (0.0, 0.70), progress, )?; device = dev; @@ -348,6 +362,12 @@ pub fn contour_body( bail!(CANCELLED); } + // Everything above the threshold, before the network has had its say — + // the yardstick the "how much was left out" figure is measured against. + // Measuring against the guided foreground instead would report almost + // nothing removed in precisely the mode that removes the most. + let above_threshold: Vec = fg.clone(); + // The network's answer is coarse by construction — it is planned at // 6 mm or 1.5 mm — so it is grown by a margin and used as a *mask* on // the thresholded image. What survives has the network's semantics and @@ -520,11 +540,11 @@ pub fn contour_body( body = morph::close_mm(&body, dims, spacing, params.close_mm); } // Everything above the threshold that is not patient: the equipment the - // extrusion test caught, plus every component too small or too detached - // to be a body. Counted here, against the original foreground, because - // by now the body also contains an interior that was never above the - // threshold at all. - let rejected: u64 = fg + // extrusion test caught, whatever the network excluded, and every + // component too small or too detached to be a body. Counted against the + // *unguided* foreground, because by now the body also contains an + // interior that was never above the threshold at all. + let rejected: u64 = above_threshold .par_iter() .zip(body.par_iter()) .map(|(&f, &b)| u64::from(f != 0 && b == 0)) @@ -548,7 +568,6 @@ pub fn contour_body( progress.report(1.0, "Body contour finished"); Ok(BodyResult { mask: body, - dims, voxels, cm3: voxels as f64 * voxel_cm3, pieces, diff --git a/src/dicom_export.rs b/src/dicom_export.rs index 8f2792b..f04553f 100644 --- a/src/dicom_export.rs +++ b/src/dicom_export.rs @@ -294,6 +294,118 @@ pub(crate) fn write_object(obj: InMemDicomObject, sop_class: &str, path: &Path) Ok(()) } +/// Build the RTSTRUCT object for one structure set. +/// +/// Split out of [`export_study`] so the archive can write the same object +/// against the study it already belongs to (`export_derived`) instead of the +/// fresh one a full export invents. +fn build_rtstruct( + ss: &crate::rtstruct::StructureSet, + ctx: &Ctx, + series_number: i64, + sop_uid: &str, +) -> InMemDicomObject { + let mut o = InMemDicomObject::new_empty(); + common_elements(&mut o, ctx, "RTSTRUCT"); + put_str(&mut o, tags::SOP_CLASS_UID, VR::UI, SOP_RTSTRUCT); + put_str(&mut o, tags::SOP_INSTANCE_UID, VR::UI, sop_uid.to_string()); + put_str(&mut o, tags::SERIES_INSTANCE_UID, VR::UI, new_uid()); + put_is(&mut o, tags::SERIES_NUMBER, series_number); + put_str( + &mut o, + tags::STRUCTURE_SET_LABEL, + VR::SH, + truncate(&ss.label, 16), + ); + put_str(&mut o, tags::STRUCTURE_SET_DATE, VR::DA, ctx.date.clone()); + put_str(&mut o, tags::STRUCTURE_SET_TIME, VR::TM, ctx.time.clone()); + + // Referenced frame of reference. + let mut rfr = InMemDicomObject::new_empty(); + put_str( + &mut rfr, + tags::FRAME_OF_REFERENCE_UID, + VR::UI, + ctx.for_uid.clone(), + ); + put_seq( + &mut o, + tags::REFERENCED_FRAME_OF_REFERENCE_SEQUENCE, + vec![rfr], + ); + + let mut ssr = Vec::new(); + let mut rcs = Vec::new(); + let mut obs = Vec::new(); + for roi in &ss.rois { + let mut s = InMemDicomObject::new_empty(); + put_is(&mut s, tags::ROI_NUMBER, roi.number as i64); + put_str( + &mut s, + tags::REFERENCED_FRAME_OF_REFERENCE_UID, + VR::UI, + ctx.for_uid.clone(), + ); + put_str(&mut s, tags::ROI_NAME, VR::LO, roi.name.clone()); + put_str(&mut s, tags::ROI_GENERATION_ALGORITHM, VR::CS, "AUTOMATIC"); + ssr.push(s); + + let mut rc = InMemDicomObject::new_empty(); + put_is(&mut rc, tags::REFERENCED_ROI_NUMBER, roi.number as i64); + put_strs( + &mut rc, + tags::ROI_DISPLAY_COLOR, + VR::IS, + &[ + roi.color[0].to_string(), + roi.color[1].to_string(), + roi.color[2].to_string(), + ], + ); + let mut contours = Vec::with_capacity(roi.contours.len()); + for c in &roi.contours { + let mut co = InMemDicomObject::new_empty(); + put_str( + &mut co, + tags::CONTOUR_GEOMETRIC_TYPE, + VR::CS, + c.geometric_type.clone(), + ); + put_is( + &mut co, + tags::NUMBER_OF_CONTOUR_POINTS, + c.points.len() as i64, + ); + let data: Vec = c + .points + .iter() + .flat_map(|p| [fmt_ds(p.x), fmt_ds(p.y), fmt_ds(p.z)]) + .collect(); + put_strs(&mut co, tags::CONTOUR_DATA, VR::DS, &data); + contours.push(co); + } + put_seq(&mut rc, tags::CONTOUR_SEQUENCE, contours); + rcs.push(rc); + + let mut ob = InMemDicomObject::new_empty(); + put_is(&mut ob, tags::OBSERVATION_NUMBER, roi.number as i64); + put_is(&mut ob, tags::REFERENCED_ROI_NUMBER, roi.number as i64); + put_str( + &mut ob, + tags::RTROI_INTERPRETED_TYPE, + VR::CS, + roi.roi_type.clone(), + ); + put_str(&mut ob, tags::ROI_INTERPRETER, VR::PN, ""); + obs.push(ob); + } + put_seq(&mut o, tags::STRUCTURE_SET_ROI_SEQUENCE, ssr); + put_seq(&mut o, tags::ROI_CONTOUR_SEQUENCE, rcs); + put_seq(&mut o, tags::RTROI_OBSERVATIONS_SEQUENCE, obs); + + o +} + /// Export `study` into `dir` as individual DICOM files. /// Returns the number of files written. pub fn export_study( @@ -435,104 +547,7 @@ pub fn export_study( si + 1, study.structure_sets.len() )); - let mut o = InMemDicomObject::new_empty(); - common_elements(&mut o, &ctx, "RTSTRUCT"); - put_str(&mut o, tags::SOP_CLASS_UID, VR::UI, SOP_RTSTRUCT); - put_str(&mut o, tags::SOP_INSTANCE_UID, VR::UI, rs_uids[si].clone()); - put_str(&mut o, tags::SERIES_INSTANCE_UID, VR::UI, new_uid()); - put_is(&mut o, tags::SERIES_NUMBER, 2 + si as i64); - put_str( - &mut o, - tags::STRUCTURE_SET_LABEL, - VR::SH, - truncate(&ss.label, 16), - ); - put_str(&mut o, tags::STRUCTURE_SET_DATE, VR::DA, ctx.date.clone()); - put_str(&mut o, tags::STRUCTURE_SET_TIME, VR::TM, ctx.time.clone()); - - // Referenced frame of reference. - let mut rfr = InMemDicomObject::new_empty(); - put_str( - &mut rfr, - tags::FRAME_OF_REFERENCE_UID, - VR::UI, - ctx.for_uid.clone(), - ); - put_seq( - &mut o, - tags::REFERENCED_FRAME_OF_REFERENCE_SEQUENCE, - vec![rfr], - ); - - let mut ssr = Vec::new(); - let mut rcs = Vec::new(); - let mut obs = Vec::new(); - for roi in &ss.rois { - let mut s = InMemDicomObject::new_empty(); - put_is(&mut s, tags::ROI_NUMBER, roi.number as i64); - put_str( - &mut s, - tags::REFERENCED_FRAME_OF_REFERENCE_UID, - VR::UI, - ctx.for_uid.clone(), - ); - put_str(&mut s, tags::ROI_NAME, VR::LO, roi.name.clone()); - put_str(&mut s, tags::ROI_GENERATION_ALGORITHM, VR::CS, "AUTOMATIC"); - ssr.push(s); - - let mut rc = InMemDicomObject::new_empty(); - put_is(&mut rc, tags::REFERENCED_ROI_NUMBER, roi.number as i64); - put_strs( - &mut rc, - tags::ROI_DISPLAY_COLOR, - VR::IS, - &[ - roi.color[0].to_string(), - roi.color[1].to_string(), - roi.color[2].to_string(), - ], - ); - let mut contours = Vec::with_capacity(roi.contours.len()); - for c in &roi.contours { - let mut co = InMemDicomObject::new_empty(); - put_str( - &mut co, - tags::CONTOUR_GEOMETRIC_TYPE, - VR::CS, - c.geometric_type.clone(), - ); - put_is( - &mut co, - tags::NUMBER_OF_CONTOUR_POINTS, - c.points.len() as i64, - ); - let data: Vec = c - .points - .iter() - .flat_map(|p| [fmt_ds(p.x), fmt_ds(p.y), fmt_ds(p.z)]) - .collect(); - put_strs(&mut co, tags::CONTOUR_DATA, VR::DS, &data); - contours.push(co); - } - put_seq(&mut rc, tags::CONTOUR_SEQUENCE, contours); - rcs.push(rc); - - let mut ob = InMemDicomObject::new_empty(); - put_is(&mut ob, tags::OBSERVATION_NUMBER, roi.number as i64); - put_is(&mut ob, tags::REFERENCED_ROI_NUMBER, roi.number as i64); - put_str( - &mut ob, - tags::RTROI_INTERPRETED_TYPE, - VR::CS, - roi.roi_type.clone(), - ); - put_str(&mut ob, tags::ROI_INTERPRETER, VR::PN, ""); - obs.push(ob); - } - put_seq(&mut o, tags::STRUCTURE_SET_ROI_SEQUENCE, ssr); - put_seq(&mut o, tags::ROI_CONTOUR_SEQUENCE, rcs); - put_seq(&mut o, tags::RTROI_OBSERVATIONS_SEQUENCE, obs); - + let o = build_rtstruct(ss, &ctx, 2 + si as i64, &rs_uids[si]); write_object(o, SOP_RTSTRUCT, &dir.join(format!("RS_export_{si}.dcm")))?; n_files += 1; } @@ -945,8 +960,6 @@ pub fn write_deformable_registration( if field.is_empty() { anyhow::bail!("the vector field is empty"); } - let now = SystemTime::now(); - let _ = now; let mut grid = InMemDicomObject::new_empty(); put_ds( &mut grid, @@ -1054,3 +1067,99 @@ fn sanitize_cs(s: &str) -> String { } out } + +/// Write only the objects this application produces — RT structure sets and +/// DICOM Segmentation series — into `dir`, keeping the study and frame of +/// reference each already belongs to. +/// +/// This is the other half of [`export_study`], and the difference is the +/// whole point: a full export invents a new study so the result stands on +/// its own, whereas contours and segments drawn on a study that already +/// exists must attach *to that study*. Fresh SOP Instance UIDs, original +/// Study Instance UID and Frame of Reference UID — which is exactly what +/// sending derived objects back to an archive means. +/// +/// Returns the number of files written. +pub fn export_derived( + study: &LoadedStudy, + dir: &Path, + params: &ExportParams, + progress: &Progress, +) -> Result { + std::fs::create_dir_all(dir).with_context(|| format!("create directory {}", dir.display()))?; + let (today_date, today_time) = today(); + let vol_for = study.volume.frame_of_reference_uid.clone(); + // The study an object belongs to, from the object itself where it says + // so and from the series it references otherwise. + let study_of = |own: &str, referenced: &str| -> String { + if !own.is_empty() { + return own.to_string(); + } + study + .series + .iter() + .find(|se| se.uid == referenced) + .map(|se| se.study_uid.clone()) + .or_else(|| study.series.first().map(|se| se.study_uid.clone())) + .unwrap_or_default() + }; + let mut n_files = 0usize; + + for (si, ss) in study.structure_sets.iter().enumerate() { + if ss.rois.is_empty() { + continue; + } + progress.set(format!( + "Writing RTSTRUCT {}/{}…", + si + 1, + study.structure_sets.len() + )); + let ctx = Ctx { + study_uid: study_of(&ss.study_uid, &ss.referenced_series_uid), + for_uid: if ss.frame_of_reference_uid.is_empty() { + vol_for.clone() + } else { + ss.frame_of_reference_uid.clone() + }, + date: today_date.clone(), + time: today_time.clone(), + params, + }; + let o = build_rtstruct(ss, &ctx, 2 + si as i64, &new_uid()); + write_object(o, SOP_RTSTRUCT, &dir.join(format!("RS_derived_{si}.dcm")))?; + n_files += 1; + } + + for (gi, ser) in study.seg_series.iter().enumerate() { + if ser.segs.iter().all(|s| s.count == 0) { + continue; + } + progress.set(format!( + "Writing SEG {}/{}…", + gi + 1, + study.seg_series.len() + )); + let study_uid = study_of(&ser.study_uid, &ser.referenced_series_uid); + let for_uid = if ser.grid.frame_of_reference_uid.is_empty() { + vol_for.clone() + } else { + ser.grid.frame_of_reference_uid.clone() + }; + let seg_ctx = dicomseg::SegWriteCtx { + study_uid: &study_uid, + for_uid: &for_uid, + date: &today_date, + time: &today_time, + series_number: 20 + gi as i64, + // The image series it was drawn on is already in the archive, so + // naming it is a real cross-reference rather than a claim about + // files written beside it. + image_series_uid: &ser.referenced_series_uid, + image_sop_uids: &[], + params, + }; + dicomseg::write(ser, &seg_ctx, &dir.join(format!("SEG_derived_{gi}.dcm")))?; + n_files += 1; + } + Ok(n_files) +} diff --git a/src/dicomseg.rs b/src/dicomseg.rs index 6eefed8..9430c8f 100644 --- a/src/dicomseg.rs +++ b/src/dicomseg.rs @@ -53,7 +53,6 @@ pub struct SegSeries { /// Series description / content label shown in the tree. pub label: String, pub sop_instance_uid: String, - pub series_uid: String, /// Study this series belongs to. pub study_uid: String, /// Image series the segments belong to (ReferencedSeriesSequence). @@ -76,7 +75,6 @@ impl SegSeries { SegSeries { label, sop_instance_uid: new_uid(), - series_uid: new_uid(), study_uid, referenced_series_uid, file_name: String::new(), @@ -85,10 +83,6 @@ impl SegSeries { } } - pub fn dims(&self) -> [usize; 3] { - self.grid.dims - } - /// Resample every segment onto `vol`'s lattice, so the overlays, the /// brush and the meshes can all index them with the displayed volume's /// dimensions. A no-op when the series is already on that lattice. @@ -520,7 +514,6 @@ pub fn load(path: &Path) -> Result { .or_else(|| str_of(&obj, tags::CONTENT_DESCRIPTION)) .unwrap_or_else(|| "Segmentation".into()), sop_instance_uid: str_of(&obj, tags::SOP_INSTANCE_UID).unwrap_or_default(), - series_uid: str_of(&obj, tags::SERIES_INSTANCE_UID).unwrap_or_default(), study_uid: str_of(&obj, tags::STUDY_INSTANCE_UID).unwrap_or_default(), referenced_series_uid, file_name: path diff --git a/src/drr.rs b/src/drr.rs index 08e8358..7f42205 100644 --- a/src/drr.rs +++ b/src/drr.rs @@ -385,7 +385,6 @@ impl DrrImage { pub struct DrrComparison { pub max_abs: f32, pub mean_abs: f32, - pub rms: f32, /// Mean absolute difference as a fraction of the mean image value. pub relative: f32, /// Pearson correlation of the two images. @@ -405,13 +404,12 @@ impl DrrComparison { sb += *y as f64; } let (ma, mb) = (sa / n, sb / n); - let (mut max_abs, mut sum_abs, mut sum_sq) = (0.0f64, 0.0f64, 0.0f64); + let (mut max_abs, mut sum_abs) = (0.0f64, 0.0f64); let (mut caa, mut cbb, mut cab) = (0.0f64, 0.0f64, 0.0f64); for (x, y) in a.pixels.iter().zip(&b.pixels) { let d = (*x - *y) as f64; max_abs = max_abs.max(d.abs()); sum_abs += d.abs(); - sum_sq += d * d; let (da, db) = (*x as f64 - ma, *y as f64 - mb); caa += da * da; cbb += db * db; @@ -421,7 +419,6 @@ impl DrrComparison { Some(DrrComparison { max_abs: max_abs as f32, mean_abs: mean_abs as f32, - rms: (sum_sq / n).sqrt() as f32, relative: if ma.abs() > 1e-9 { (mean_abs / ma.abs()) as f32 } else { diff --git a/src/dvh.rs b/src/dvh.rs new file mode 100644 index 0000000..f2bffaa --- /dev/null +++ b/src/dvh.rs @@ -0,0 +1,919 @@ +//! Dose–volume histograms: the numbers a plan is actually judged on. +//! +//! A DVH answers one question — how much of this structure receives at least +//! this dose — and every constraint in every protocol is a reading off that +//! curve. The arithmetic is elementary; what makes an implementation right or +//! wrong is the bookkeeping around it, and this module is deliberate about +//! four things that are easy to get quietly wrong: +//! +//! * **Where it samples.** The structure's own lattice, not the dose grid. +//! A CT mask is 1 mm and a dose grid is 2–3 mm, so walking the mask and +//! interpolating the dose gives a curve with the structure's resolution +//! rather than the dose's. The walk is affine, so the dose grid +//! coordinates are stepped rather than recomputed per voxel. +//! +//! * **What falls outside the dose grid.** Counted, kept, and reported. +//! Those voxels enter the histogram at zero dose — which is the honest +//! reading of "this part of the structure was not irradiated by *this* +//! dose object" — but a DVH silently computed over 60 % of a structure is +//! a wrong DVH, so [`Dvh::outside_fraction`] exists and the interface +//! shows it. +//! +//! * **Statistics from the samples, not the bins.** Minimum, mean and +//! maximum are accumulated during the walk. Reading them off a binned +//! histogram costs half a bin width of accuracy for no reason. +//! +//! * **Interpolation inside a bin.** D95 % is almost never exactly at a bin +//! edge. The cumulative curve is interpolated linearly between edges, so a +//! finer bin width changes the answer by less than the bin width rather +//! than by a whole one. +//! +//! Nothing here knows about the interface: a [`Dvh`] is computed from a mask, +//! a lattice and a [`DoseGrid`], and everything else — metrics, constraints, +//! CSV — is a pure function of it. + +use std::fmt::Write as _; + +use anyhow::{bail, Result}; + +use crate::rtdose::DoseGrid; +use crate::volume::Grid; + +/// How finely the histogram is binned, as a fraction of the dose maximum. +/// 2000 bins over a 70 Gy plan is 3.5 cGy — finer than any constraint is +/// quoted to, and small enough that interpolation inside a bin is a +/// formality. +const DEFAULT_BINS: usize = 2000; + +/// One structure's dose–volume histogram. +#[derive(Clone, Debug)] +pub struct Dvh { + pub name: String, + pub color: [u8; 3], + /// Which dose object this was computed against — shown in the legend, + /// because overlaying two plans is the whole point of allowing more than + /// one. + pub dose_label: String, + /// Dose units of the source grid, "GY" or "RELATIVE". + pub units: String, + /// Volume in cm³ per bin; bin `b` covers dose `[b·w, (b+1)·w)`. + pub bins: Vec, + pub bin_width: f64, + /// Volume of the whole structure, including anything outside the dose + /// grid, in cm³. + pub volume_cm3: f64, + /// Volume that fell outside the dose grid, in cm³. It is in `bins[0]`. + pub outside_cm3: f64, + /// Over the voxels that *were* inside the dose grid. + pub min: f64, + pub max: f64, + pub mean: f64, +} + +impl Dvh { + /// Fraction of the structure that lay outside the dose grid, 0‥1. + pub fn outside_fraction(&self) -> f64 { + if self.volume_cm3 <= 0.0 { + 0.0 + } else { + self.outside_cm3 / self.volume_cm3 + } + } + + /// Dose at the upper edge of the last non-empty bin — where the curve + /// stops being worth drawing. + pub fn dose_extent(&self) -> f64 { + let last = self.bins.iter().rposition(|v| *v > 0.0).unwrap_or(0); + (last + 1) as f64 * self.bin_width + } + + /// The cumulative curve: `(dose, volume ≥ that dose in cm³)` at every bin + /// edge, starting at `(0, whole structure)`. + pub fn cumulative(&self) -> Vec<(f64, f64)> { + let mut out = Vec::with_capacity(self.bins.len() + 1); + let mut remaining = self.bins.iter().sum::(); + for (b, v) in self.bins.iter().enumerate() { + out.push((b as f64 * self.bin_width, remaining)); + remaining -= *v; + } + out.push((self.bins.len() as f64 * self.bin_width, remaining.max(0.0))); + out + } + + /// The differential curve: `(bin centre, volume in that bin in cm³)`. + pub fn differential(&self) -> Vec<(f64, f64)> { + self.bins + .iter() + .enumerate() + .map(|(b, v)| ((b as f64 + 0.5) * self.bin_width, *v)) + .collect() + } + + /// Volume receiving at least `dose`, in cm³. Linear inside a bin. + pub fn volume_at_dose(&self, dose: f64) -> f64 { + if dose <= 0.0 { + return self.bins.iter().sum(); + } + let x = dose / self.bin_width; + let b = x.floor() as usize; + if b >= self.bins.len() { + return 0.0; + } + // Volume at or above the bin's lower edge, less the part of this + // bin's own volume that lies below `dose`. + let above: f64 = self.bins[b..].iter().sum(); + let frac_into_bin = x - b as f64; + (above - self.bins[b] * frac_into_bin).max(0.0) + } + + /// Volume receiving at least `dose`, as a fraction of the structure. + pub fn volume_fraction_at_dose(&self, dose: f64) -> f64 { + if self.volume_cm3 <= 0.0 { + 0.0 + } else { + self.volume_at_dose(dose) / self.volume_cm3 + } + } + + /// The dose that `volume_cm3` of the structure receives at least — D2cc + /// and friends. Interpolated on the cumulative curve. + /// + /// Returns 0 for a volume larger than the structure, which is the honest + /// answer: every part of it receives at least nothing. + pub fn dose_at_volume(&self, volume_cm3: f64) -> f64 { + let total: f64 = self.bins.iter().sum(); + if volume_cm3 <= 0.0 { + return self.dose_extent(); + } + if volume_cm3 >= total { + return 0.0; + } + // Walk down from the top until the accumulated volume reaches the + // target, then interpolate inside the bin it happened in. + let mut acc = 0.0; + for b in (0..self.bins.len()).rev() { + let v = self.bins[b]; + if acc + v >= volume_cm3 { + if b == 0 { + // The lowest bin holds exact zeros — voxels outside the + // dose grid, and anything the plan genuinely misses. + // Interpolating across it would report a few hundredths + // of a Gy for a structure half of which is unirradiated, + // which reads as a real dose and is not one. + return 0.0; + } + let need = volume_cm3 - acc; + let frac = if v > 0.0 { need / v } else { 0.0 }; + // `frac` of this bin, measured down from its upper edge. + return (b as f64 + 1.0 - frac) * self.bin_width; + } + acc += v; + } + 0.0 + } + + /// The dose that `fraction` (0‥1) of the structure receives at least — + /// D95 %, D2 %. + pub fn dose_at_volume_fraction(&self, fraction: f64) -> f64 { + self.dose_at_volume(fraction * self.volume_cm3) + } +} + +/// Everything one DVH run is told about how to bin. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct DvhParams { + /// Bin width in dose units. `None` derives it from the dose maximum. + pub bin_width: Option, +} + +/// Compute one structure's DVH. +/// +/// `mask` is a 0/1 mask on `grid`; `dose` is sampled at the patient position +/// of every set voxel's centre. +pub fn compute( + name: &str, + color: [u8; 3], + mask: &[u8], + grid: &Grid, + dose: &DoseGrid, + params: DvhParams, +) -> Result { + let [nx, ny, nz] = grid.dims; + if mask.len() != nx * ny * nz { + bail!("the mask does not match its lattice"); + } + let voxel_cm3 = grid.spacing[0] * grid.spacing[1] * grid.spacing[2] / 1000.0; + let top = (dose.max_dose as f64).max(1e-6); + let bin_width = params.bin_width.unwrap_or(top / DEFAULT_BINS as f64); + if !bin_width.is_finite() || bin_width <= 0.0 { + bail!("the bin width must be a positive number of dose units"); + } + // One bin past the maximum, so the hottest voxel has somewhere to land. + let n_bins = ((top / bin_width).ceil() as usize + 1).max(2); + let mut bins = vec![0.0f64; n_bins]; + + // The dose grid's coordinates are an affine function of position, so the + // walk steps them instead of projecting each voxel: three adds per voxel + // rather than three dot products. + let base = dose.grid_coords(grid.voxel_to_patient(0.0, 0.0, 0.0)); + let step_i = delta(dose, grid, 0, base); + let step_j = delta(dose, grid, 1, base); + let step_k = delta(dose, grid, 2, base); + + let (mut n_in, mut n_out) = (0u64, 0u64); + let (mut lo, mut hi, mut sum) = (f64::INFINITY, f64::NEG_INFINITY, 0.0f64); + for k in 0..nz { + for j in 0..ny { + let row = k * nx * ny + j * nx; + let start = [ + base[0] + step_j[0] * j as f64 + step_k[0] * k as f64, + base[1] + step_j[1] * j as f64 + step_k[1] * k as f64, + base[2] + step_j[2] * j as f64 + step_k[2] * k as f64, + ]; + for i in 0..nx { + if mask[row + i] == 0 { + continue; + } + let uvw = [ + start[0] + step_i[0] * i as f64, + start[1] + step_i[1] * i as f64, + start[2] + step_i[2] * i as f64, + ]; + match dose.sample_uvw(uvw) { + Some(d) => { + let d = d as f64; + n_in += 1; + sum += d; + lo = lo.min(d); + hi = hi.max(d); + let b = ((d / bin_width) as usize).min(n_bins - 1); + bins[b] += voxel_cm3; + } + None => { + n_out += 1; + bins[0] += voxel_cm3; + } + } + } + } + } + if n_in + n_out == 0 { + bail!("'{name}' has no voxels on this lattice"); + } + Ok(Dvh { + name: name.to_string(), + color, + dose_label: dose.label.clone(), + units: dose.units.clone(), + bins, + bin_width, + volume_cm3: (n_in + n_out) as f64 * voxel_cm3, + outside_cm3: n_out as f64 * voxel_cm3, + min: if n_in > 0 { lo } else { 0.0 }, + max: if n_in > 0 { hi } else { 0.0 }, + mean: if n_in > 0 { sum / n_in as f64 } else { 0.0 }, + }) +} + +/// How the dose grid coordinates change per step of one lattice axis. +fn delta(dose: &DoseGrid, grid: &Grid, axis: usize, base: [f64; 3]) -> [f64; 3] { + let one = match axis { + 0 => grid.voxel_to_patient(1.0, 0.0, 0.0), + 1 => grid.voxel_to_patient(0.0, 1.0, 0.0), + _ => grid.voxel_to_patient(0.0, 0.0, 1.0), + }; + let c = dose.grid_coords(one); + [c[0] - base[0], c[1] - base[1], c[2] - base[2]] +} + +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- + +/// One readable number off a curve. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Metric { + Volume, + Min, + Mean, + Max, + /// Dose to at least this percentage of the structure — `D95%`. + DoseAtPct(f64), + /// Dose to at least this absolute volume — `D2cc`. + DoseAtCc(f64), + /// Percentage of the structure at or above this dose — `V20Gy`. + VolumePctAtDose(f64), + /// Absolute volume at or above this dose — `V20Gy[cc]`. + VolumeCcAtDose(f64), +} + +impl Metric { + /// `D95%`, `V20`, `Dmean` — the column heading, and what the protocol + /// file writes (`V20Gy` is accepted on input). + pub fn label(&self) -> String { + match self { + Metric::Volume => "Volume".into(), + Metric::Min => "Dmin".into(), + Metric::Mean => "Dmean".into(), + Metric::Max => "Dmax".into(), + Metric::DoseAtPct(p) => format!("D{}%", trim(*p)), + Metric::DoseAtCc(v) => format!("D{}cc", trim(*v)), + Metric::VolumePctAtDose(d) => format!("V{}", trim(*d)), + Metric::VolumeCcAtDose(d) => format!("V{}cc", trim(*d)), + } + } + + /// What the number is in: dose units, cm³ or per cent. + pub fn unit(&self, dose_units: &str) -> String { + match self { + Metric::Volume | Metric::VolumeCcAtDose(_) => "cm³".into(), + Metric::VolumePctAtDose(_) => "%".into(), + _ => nice_units(dose_units), + } + } + + /// True when the value is a dose, and so follows the dose axis when the + /// window is showing per cent of the prescription. + pub fn is_dose(&self) -> bool { + matches!( + self, + Metric::Min | Metric::Mean | Metric::Max | Metric::DoseAtPct(_) | Metric::DoseAtCc(_) + ) + } + + pub fn evaluate(&self, dvh: &Dvh) -> f64 { + match *self { + Metric::Volume => dvh.volume_cm3, + Metric::Min => dvh.min, + Metric::Mean => dvh.mean, + Metric::Max => dvh.max, + Metric::DoseAtPct(p) => dvh.dose_at_volume_fraction(p / 100.0), + Metric::DoseAtCc(v) => dvh.dose_at_volume(v), + Metric::VolumePctAtDose(d) => dvh.volume_fraction_at_dose(d) * 100.0, + Metric::VolumeCcAtDose(d) => dvh.volume_at_dose(d), + } + } + + /// Parse a column or constraint name: `Dmean`, `D95%`, `D2cc`, `V20Gy`, + /// `V20Gy[cc]`. Case-insensitive. + pub fn parse(s: &str) -> Option { + let t = s.trim(); + let lower = t.to_lowercase(); + match lower.as_str() { + "volume" | "vol" => return Some(Metric::Volume), + "dmin" | "min" => return Some(Metric::Min), + "dmean" | "mean" => return Some(Metric::Mean), + "dmax" | "max" => return Some(Metric::Max), + _ => {} + } + let (head, rest) = lower.split_at(1); + match head { + "d" => { + if let Some(v) = rest.strip_suffix("cc") { + v.trim().parse().ok().map(Metric::DoseAtCc) + } else { + rest.trim_end_matches('%') + .trim() + .parse() + .ok() + .map(Metric::DoseAtPct) + } + } + "v" => { + // Both the label form (`V20cc`) and the explicit one + // (`V20Gy[cc]`); `label()` emits the first, so the two have + // to agree or a protocol cannot survive being saved. + let (body, absolute) = match rest.strip_suffix("[cc]") { + Some(b) => (b, true), + None => match rest.strip_suffix("cc") { + Some(b) => (b, true), + None => (rest, false), + }, + }; + let d: f64 = body + .trim_end_matches("gy") + .trim_end_matches('%') + .trim() + .parse() + .ok()?; + Some(if absolute { + Metric::VolumeCcAtDose(d) + } else { + Metric::VolumePctAtDose(d) + }) + } + _ => None, + } + } +} + +/// The columns every table starts with. +pub fn default_metrics() -> Vec { + vec![ + Metric::Volume, + Metric::Min, + Metric::Mean, + Metric::Max, + Metric::DoseAtPct(95.0), + Metric::DoseAtPct(2.0), + ] +} + +fn trim(v: f64) -> String { + let s = format!("{v:.2}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() { + "0".into() + } else { + s + } +} + +/// "GY" as it should be printed. +pub fn nice_units(units: &str) -> String { + match units.to_uppercase().as_str() { + "GY" => "Gy".into(), + "RELATIVE" => "%".into(), + "" => "Gy".into(), + other => other.to_string(), + } +} + +// --------------------------------------------------------------------------- +// Constraints +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Cmp { + AtMost, + AtLeast, +} + +impl Cmp { + pub fn symbol(&self) -> &'static str { + match self { + Cmp::AtMost => "<=", + Cmp::AtLeast => ">=", + } + } + fn holds(&self, value: f64, limit: f64) -> bool { + match self { + Cmp::AtMost => value <= limit, + Cmp::AtLeast => value >= limit, + } + } +} + +/// One line of a protocol: *this structure's* `metric` must be at most or at +/// least `limit`. +#[derive(Clone, Debug, PartialEq)] +pub struct Constraint { + /// Matched against the structure name, case-insensitively; a leading or + /// trailing `*` matches loosely, so `PTV*` catches `PTV_5400`. + pub structure: String, + pub metric: Metric, + pub cmp: Cmp, + pub limit: f64, +} + +impl Constraint { + pub fn matches(&self, name: &str) -> bool { + let (pat, name) = (self.structure.to_lowercase(), name.to_lowercase()); + match (pat.strip_prefix('*'), pat.strip_suffix('*')) { + (Some(p), Some(_)) => { + let inner = p.strip_suffix('*').unwrap_or(p); + name.contains(inner) + } + (Some(p), None) => name.ends_with(p), + (None, Some(p)) => name.starts_with(p), + (None, None) => name == pat, + } + } + + /// `Cord Dmax <= 45` — the line a protocol file holds. A name with a + /// space in it is quoted, because that is how it is read back. + pub fn to_line(&self) -> String { + let name = if self.structure.contains(char::is_whitespace) { + format!("\"{}\"", self.structure) + } else { + self.structure.clone() + }; + format!( + "{name} {} {} {}", + self.metric.label(), + self.cmp.symbol(), + trim(self.limit) + ) + } +} + +/// How one constraint came out. +#[derive(Clone, Debug)] +pub struct Verdict { + pub constraint: Constraint, + /// The structure it was matched against; empty when nothing matched. + pub structure: String, + pub value: Option, + pub pass: bool, +} + +/// Evaluate a protocol against the curves on screen. +/// +/// A constraint that matches nothing is reported with no value and does not +/// pass — a protocol line that silently evaluates to "fine" because the +/// structure was never contoured is the worst possible failure mode. +pub fn check(constraints: &[Constraint], curves: &[Dvh]) -> Vec { + constraints + .iter() + .map(|c| match curves.iter().find(|d| c.matches(&d.name)) { + Some(d) => { + let value = c.metric.evaluate(d); + Verdict { + constraint: c.clone(), + structure: d.name.clone(), + value: Some(value), + pass: c.cmp.holds(value, c.limit), + } + } + None => Verdict { + constraint: c.clone(), + structure: String::new(), + value: None, + pass: false, + }, + }) + .collect() +} + +/// Read a protocol: one constraint per line, `#` comments, blank lines +/// ignored. Structure names may be quoted when they contain spaces. +pub fn parse_protocol(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let line = line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + return None; + } + let (structure, rest) = if let Some(r) = line.strip_prefix('"') { + let (name, rest) = r.split_once('"')?; + (name.to_string(), rest) + } else { + let (name, rest) = line.split_once(char::is_whitespace)?; + (name.to_string(), rest) + }; + let mut it = rest.split_whitespace(); + let metric = Metric::parse(it.next()?)?; + let cmp = match it.next()? { + "<=" | "<" => Cmp::AtMost, + ">=" | ">" => Cmp::AtLeast, + _ => return None, + }; + let limit = it.next()?.parse().ok()?; + Some(Constraint { + structure, + metric, + cmp, + limit, + }) + }) + .collect() +} + +/// Write a protocol back out, so what was loaded can be edited and saved. +pub fn write_protocol(constraints: &[Constraint]) -> String { + let mut s = String::from("# One constraint per line: STRUCTURE METRIC <=|>= LIMIT\n"); + for c in constraints { + let _ = writeln!(s, "{}", c.to_line()); + } + s +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// The cumulative curves as CSV: one dose column, then one volume column per +/// structure. Curves may have different bin widths (different dose grids), so +/// they are resampled onto one dose axis rather than assumed to share one. +pub fn curves_csv(curves: &[Dvh], relative_volume: bool) -> String { + let mut out = String::new(); + if curves.is_empty() { + return out; + } + let width = curves + .iter() + .map(|c| c.bin_width) + .fold(f64::INFINITY, f64::min); + let extent = curves.iter().map(|c| c.dose_extent()).fold(0.0, f64::max); + let steps = ((extent / width).ceil() as usize).clamp(1, 100_000); + let units = nice_units(&curves[0].units); + out.push_str(&format!("Dose [{units}]")); + for c in curves { + let _ = write!( + out, + ",{} [{}]{}", + c.name, + if relative_volume { "%" } else { "cm³" }, + if c.dose_label.is_empty() { + String::new() + } else { + format!(" ({})", c.dose_label) + } + ); + } + out.push('\n'); + for s in 0..=steps { + let d = s as f64 * width; + let _ = write!(out, "{d:.4}"); + for c in curves { + let v = if relative_volume { + c.volume_fraction_at_dose(d) * 100.0 + } else { + c.volume_at_dose(d) + }; + let _ = write!(out, ",{v:.4}"); + } + out.push('\n'); + } + out +} + +/// The metrics table as CSV. +pub fn metrics_csv(curves: &[Dvh], metrics: &[Metric]) -> String { + let mut out = String::from("Structure,Dose"); + for m in metrics { + let _ = write!( + out, + ",{} [{}]", + m.label(), + m.unit(curves.first().map(|c| c.units.as_str()).unwrap_or("GY")) + ); + } + out.push('\n'); + for c in curves { + let _ = write!(out, "{},{}", c.name, c.dose_label); + for m in metrics { + let _ = write!(out, ",{:.4}", m.evaluate(c)); + } + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::Vec3; + + /// A dose grid that ramps linearly from 0 to `top` along +x, on a 1 mm + /// lattice — every DVH over it can be worked out on paper. + fn ramp(top: f32, nx: usize, ny: usize, nz: usize) -> DoseGrid { + let mut data = vec![0f32; nx * ny * nz]; + for f in 0..nz { + for j in 0..ny { + for i in 0..nx { + data[f * nx * ny + j * nx + i] = top * i as f32 / (nx - 1) as f32; + } + } + } + DoseGrid { + data, + dims: [nx, ny, nz], + spacing: [1.0, 1.0], + origin: Vec3::new(0.0, 0.0, 0.0), + row_dir: Vec3::new(1.0, 0.0, 0.0), + col_dir: Vec3::new(0.0, 1.0, 0.0), + normal: Vec3::new(0.0, 0.0, 1.0), + offsets: (0..nz).map(|f| f as f64).collect(), + units: "GY".into(), + summation_type: "PLAN".into(), + max_dose: top, + frame_of_reference_uid: "1.2.3".into(), + study_uid: String::new(), + referenced_plan_uid: String::new(), + label: "test".into(), + } + } + + fn grid(dims: [usize; 3], spacing: [f64; 3]) -> Grid { + Grid { + dims, + spacing, + origin: Vec3::new(0.0, 0.0, 0.0), + row_dir: Vec3::new(1.0, 0.0, 0.0), + col_dir: Vec3::new(0.0, 1.0, 0.0), + normal: Vec3::new(0.0, 0.0, 1.0), + frame_of_reference_uid: "1.2.3".into(), + } + } + + /// A block covering `i` in `0..nx`, on the same lattice as the dose. + fn block(g: &Grid) -> Vec { + vec![1u8; g.dims[0] * g.dims[1] * g.dims[2]] + } + + #[test] + fn a_uniform_dose_gives_a_step_and_exact_statistics() { + let mut dose = ramp(10.0, 11, 4, 3); + dose.data.iter_mut().for_each(|v| *v = 7.0); + dose.max_dose = 7.0; + let g = grid([11, 4, 3], [1.0; 3]); + let d = compute( + "block", + [255, 0, 0], + &block(&g), + &g, + &dose, + DvhParams::default(), + ) + .expect("a curve"); + assert!((d.min - 7.0).abs() < 1e-6); + assert!((d.max - 7.0).abs() < 1e-6); + assert!((d.mean - 7.0).abs() < 1e-6); + // 11 × 4 × 3 mm³ = 0.132 cm³. + assert!((d.volume_cm3 - 0.132).abs() < 1e-9, "{}", d.volume_cm3); + // Everything is above 6.9 Gy and nothing above 7.1. + assert!((d.volume_at_dose(6.9) - d.volume_cm3).abs() < 1e-9); + assert!(d.volume_at_dose(7.1) < 1e-9); + assert!((d.dose_at_volume_fraction(0.5) - 7.0).abs() < 0.05); + } + + #[test] + fn a_linear_ramp_gives_a_linear_dvh() { + // Dose runs 0‥10 Gy over 101 columns; the structure is all of them. + // The fraction receiving ≥ D is then (10 − D)/10, exactly. + let dose = ramp(10.0, 101, 3, 3); + let g = grid([101, 3, 3], [1.0; 3]); + let d = compute( + "ramp", + [0, 255, 0], + &block(&g), + &g, + &dose, + DvhParams::default(), + ) + .expect("a curve"); + assert!((d.min - 0.0).abs() < 1e-6); + assert!((d.max - 10.0).abs() < 1e-6); + assert!((d.mean - 5.0).abs() < 1e-6); + for probe in [1.0, 2.5, 5.0, 7.5, 9.0] { + let want = (10.0 - probe) / 10.0; + let got = d.volume_fraction_at_dose(probe); + assert!( + (got - want).abs() < 0.02, + "V{probe}Gy = {got:.4}, expected {want:.4}" + ); + } + // …and the inverse reading agrees. + for frac in [0.1, 0.5, 0.9] { + let want = 10.0 * (1.0 - frac); + let got = d.dose_at_volume_fraction(frac); + assert!( + (got - want).abs() < 0.15, + "D{}% = {got:.3}, expected {want:.3}", + frac * 100.0 + ); + } + } + + #[test] + fn voxels_outside_the_dose_grid_are_counted_and_reported() { + // The dose covers 11 mm in x; the structure is twice as wide. + let dose = ramp(10.0, 11, 4, 3); + let g = grid([22, 4, 3], [1.0; 3]); + let d = compute( + "wide", + [0, 0, 255], + &block(&g), + &g, + &dose, + DvhParams::default(), + ) + .expect("a curve"); + assert!( + (d.outside_fraction() - 0.5).abs() < 0.05, + "outside {:.3}", + d.outside_fraction() + ); + // Half the structure is at zero dose, so D60% must be zero… + assert!(d.dose_at_volume_fraction(0.6) < 1e-6); + // …while the statistics describe only what was irradiated. + assert!((d.max - 10.0).abs() < 1e-6); + assert!((d.mean - 5.0).abs() < 0.2, "mean {}", d.mean); + } + + #[test] + fn dose_at_volume_reads_the_curve_from_the_top() { + let dose = ramp(10.0, 101, 3, 3); + let g = grid([101, 3, 3], [1.0; 3]); + let d = + compute("ramp", [0; 3], &block(&g), &g, &dose, DvhParams::default()).expect("a curve"); + // D0 is the hottest dose; a volume beyond the structure is 0. + assert!(d.dose_at_volume(0.0) >= 9.9); + assert_eq!(d.dose_at_volume(d.volume_cm3 * 2.0), 0.0); + // D2cc on a 0.909 cm³ structure is beyond it, so also zero. + assert_eq!(d.dose_at_volume(2.0), 0.0); + } + + #[test] + fn metric_names_round_trip() { + for (text, metric) in [ + ("Dmean", Metric::Mean), + ("Dmax", Metric::Max), + ("D95%", Metric::DoseAtPct(95.0)), + ("D2cc", Metric::DoseAtCc(2.0)), + ("V20", Metric::VolumePctAtDose(20.0)), + ("V20cc", Metric::VolumeCcAtDose(20.0)), + ] { + let parsed = Metric::parse(text).unwrap_or_else(|| panic!("parse {text}")); + assert_eq!(parsed, metric, "{text}"); + } + assert_eq!(Metric::parse("V20Gy"), Some(Metric::VolumePctAtDose(20.0))); + assert_eq!(Metric::DoseAtPct(95.0).label(), "D95%"); + assert_eq!(Metric::VolumeCcAtDose(20.0).label(), "V20cc"); + assert_eq!(Metric::parse("nonsense"), None); + } + + #[test] + fn a_protocol_round_trips_and_a_missing_structure_fails_loudly() { + let text = "\ +# head and neck +Cord Dmax <= 45 +PTV* D95% >= 57 +\"Parotid L\" Dmean <= 26 +Missing Dmean <= 10 +"; + let cs = parse_protocol(text); + assert_eq!(cs.len(), 4); + assert_eq!(cs[2].structure, "Parotid L"); + assert!(cs[1].matches("PTV_5400"), "prefix wildcard"); + assert!(!cs[0].matches("Cord_PRV")); + let round = parse_protocol(&write_protocol(&cs)); + assert_eq!(round, cs); + + let dose = ramp(60.0, 61, 3, 3); + let g = grid([61, 3, 3], [1.0; 3]); + let curves = vec![ + compute("Cord", [0; 3], &block(&g), &g, &dose, DvhParams::default()).unwrap(), + compute( + "PTV_5400", + [0; 3], + &block(&g), + &g, + &dose, + DvhParams::default(), + ) + .unwrap(), + compute( + "Parotid L", + [0; 3], + &block(&g), + &g, + &dose, + DvhParams::default(), + ) + .unwrap(), + ]; + let v = check(&cs, &curves); + assert_eq!(v[0].structure, "Cord"); + assert!(!v[0].pass, "Dmax 60 > 45"); + assert_eq!(v[1].structure, "PTV_5400"); + assert!(v[3].value.is_none(), "nothing matched 'Missing'"); + assert!(!v[3].pass, "an unmatched constraint must not pass"); + } + + #[test] + fn csv_carries_one_dose_axis_for_curves_that_do_not_share_bins() { + let g = grid([21, 2, 2], [1.0; 3]); + let a = compute( + "a", + [0; 3], + &block(&g), + &g, + &ramp(20.0, 21, 2, 2), + DvhParams { + bin_width: Some(0.5), + }, + ) + .unwrap(); + let b = compute( + "b", + [0; 3], + &block(&g), + &g, + &ramp(20.0, 21, 2, 2), + DvhParams { + bin_width: Some(0.1), + }, + ) + .unwrap(); + let csv = curves_csv(&[a, b], true); + let mut lines = csv.lines(); + assert!(lines.next().unwrap().starts_with("Dose [Gy],a [%]")); + let first = lines.next().unwrap(); + assert!(first.starts_with("0.0000,100.0000,100.0000"), "{first}"); + // The finer of the two bin widths sets the step. + let second = lines.next().unwrap(); + assert!(second.starts_with("0.1000"), "{second}"); + } +} diff --git a/src/fourd.rs b/src/fourd.rs new file mode 100644 index 0000000..f4edfe2 --- /dev/null +++ b/src/fourd.rs @@ -0,0 +1,644 @@ +//! Grouping image series into 4D sub-studies. +//! +//! A 4DCT arrives as one series per respiratory phase, usually with an +//! average (and sometimes a MIP) reconstruction beside them. DICOM stores +//! no node for the acquisition they belong to — the phase lives in the +//! series description ("Thorax 4D 30%") or, for enhanced exports, in +//! TemporalPositionIdentifier. This module reconstructs that node: it +//! recognises the phase series of a study, orders them, and files the +//! companion reconstructions with them, so the data tree can show one +//! "4D" group and the motion tools can iterate over its phases. +//! +//! Detection is a heuristic over headers, so every result can be corrected +//! by hand: groups built or edited in the tree are marked [`FourDGroup:: +//! custom`] and are never replaced by re-detection. +//! +//! Members reference series by UID, not by index — series are renamed, +//! removed and moved between datasets, and a UID survives all of that +//! (an unresolvable UID simply drops out of the resolved view). + +use crate::loader::SeriesInfo; + +/// What a member series is within its group. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Role { + /// One respiratory (or cardiac) phase of the acquisition. + Phase, + /// The time-averaged reconstruction. + Average, + /// Maximum-intensity projection over the phases. + Mip, + /// Minimum-intensity projection over the phases. + MinIp, +} + +impl Role { + /// Short tag shown after the member label in the tree. + pub fn tag(self) -> &'static str { + match self { + Role::Phase => "", + Role::Average => "AVG", + Role::Mip => "MIP", + Role::MinIp => "MinIP", + } + } +} + +/// One series of a 4D group. +#[derive(Clone, Debug)] +pub struct Member { + /// SeriesInstanceUID — the stable identity of the series. + pub series_uid: String, + /// What the member is called within the group: "0%", "50%", "t3", "AVG". + pub label: String, + pub role: Role, + /// Respiratory phase in percent, when the description declared one. + pub percent: Option, +} + +/// A group of image series that form one 4D acquisition. +#[derive(Clone, Debug)] +pub struct FourDGroup { + /// Name shown in the tree; renameable. + pub name: String, + /// Study the group belongs to (all members share it). + pub study_uid: String, + /// The members, phases first in temporal order, then the + /// reconstructions (AVG, MIP, …). + pub members: Vec, + /// Built or edited by hand — re-detection must not replace it. + pub custom: bool, + /// Dissolved by hand. The group stays as a hidden tombstone so + /// re-detection does not resurrect it; an explicit *Re-detect 4D + /// groups* clears the tombstones. + pub dissolved: bool, +} + +impl FourDGroup { + /// Indices of the members' series within `series`, in member order; + /// `None` for a member whose series is gone. + pub fn resolve(&self, series: &[SeriesInfo]) -> Vec> { + self.members + .iter() + .map(|m| series.iter().position(|s| s.uid == m.series_uid)) + .collect() + } + + /// Positions (within `members`) of the phase members, in order. + pub fn phase_members(&self) -> Vec { + (0..self.members.len()) + .filter(|&i| self.members[i].role == Role::Phase) + .collect() + } + + /// The member the motion tools should use as the reference phase by + /// default: the 0 % phase when there is one, else the first phase. + pub fn default_reference(&self) -> Option { + let phases = self.phase_members(); + phases + .iter() + .copied() + .find(|&i| self.members[i].percent.is_some_and(|p| p.abs() < 0.01)) + .or_else(|| phases.first().copied()) + } + + /// `4D CT — Thorax (10 phases + 1)`, the default group name; the `+ 1` + /// counts the AVG / MIP members. + fn derive_name(modality: &str, stem: &str, n_phases: usize, extras: usize) -> String { + // Leftover separators around the removed phase number ("4DCT_") are + // not part of the name. + let stem = stem.trim_matches(|c: char| { + c.is_whitespace() || matches!(c, '_' | '-' | '—' | ':' | ',' | '.') + }); + let what = if stem.is_empty() || stem.eq_ignore_ascii_case(&format!("4D {modality}")) { + format!("4D {modality}") + } else { + format!("4D {modality} — {stem}") + }; + if extras > 0 { + format!("{what} ({n_phases} phases + {extras})") + } else { + format!("{what} ({n_phases} phases)") + } + } +} + +/// The phase hint one description carries. +#[derive(Clone, PartialEq, Debug)] +enum Hint { + /// A phase series: its number, the description with the number removed + /// (the *template*, identifying which 4D set it belongs to — original + /// case, whitespace collapsed; compared case-insensitively), and + /// whether the number was written as a literal percent ("30%") rather + /// than a keyword form ("phase_030") — which decides the label. + Phase { + number: f32, + template: String, + literal_pct: bool, + }, + Average, + Mip, + MinIp, + None, +} + +impl Hint { + /// The member label a phase hint yields: "30%" for a literal percent, + /// "phase 3" for the keyword form. + fn phase_label(number: f32, literal_pct: bool) -> String { + let n = if number.fract().abs() < 1e-3 { + format!("{}", number as i64) + } else { + format!("{number}") + }; + if literal_pct { + format!("{n}%") + } else { + format!("phase {n}") + } + } +} + +/// Parse the phase hint out of a series description. +fn hint_of(desc: &str) -> Hint { + let lower = desc.to_lowercase(); + // Projections and averages first: "MinIP" contains "mip" backwards + // ordering would misfile it. + for (needle, hint) in [ + ("minip", Hint::MinIp), + ("min-ip", Hint::MinIp), + ("min ip", Hint::MinIp), + ("mip", Hint::Mip), + ("average", Hint::Average), + ("avg", Hint::Average), + (" ave ", Hint::Average), + ("mean", Hint::Average), + ] { + if lower.contains(needle) { + return hint; + } + } + // A number immediately followed by '%' (possibly with a space): the + // respiratory phase. The description with the number removed is the + // template that tells two 4D sets in one study apart. The scan runs on + // the original bytes — indices into the lowercased copy would not be + // valid slice positions of `desc` for non-ASCII descriptions. + let bytes = desc.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if b != b'%' { + continue; + } + // Walk back over an optional space, then the digits. + let mut j = i; + if j > 0 && bytes[j - 1] == b' ' { + j -= 1; + } + let end = j; + while j > 0 && (bytes[j - 1].is_ascii_digit() || bytes[j - 1] == b'.') { + j -= 1; + } + if j == end { + continue; // '%' with no number before it + } + if let Ok(pct) = desc[j..end].parse::() { + if (0.0..=100.0).contains(&pct) { + let template = format!("{}{}", &desc[..j], &desc[i + 1..]); + return Hint::Phase { + number: pct, + template: normalize_template(&template), + literal_pct: true, + }; + } + } + } + // The keyword form: "phase" followed by separators and a number + // ("4DCT_phase_000", "Phase 3", "phase-50"). "Phase contrast" and + // friends have no number there and fall through. + let find_phase = || { + bytes + .windows(5) + .position(|w| w.eq_ignore_ascii_case(b"phase")) + }; + if let Some(p) = find_phase() { + let tail = &bytes[p + 5..]; + let mut k = 0; + while k < tail.len() && (tail[k] == b' ' || tail[k] == b'_' || tail[k] == b'-') { + k += 1; + } + let start = k; + while k < tail.len() && (tail[k].is_ascii_digit() || tail[k] == b'.') { + k += 1; + } + if k > start { + if let Ok(n) = desc[p + 5 + start..p + 5 + k].parse::() { + if (0.0..=100.0).contains(&n) { + // Template: the description with "phase…" removed. + let template = format!("{}{}", &desc[..p], &desc[p + 5 + k..]); + return Hint::Phase { + number: n, + template: normalize_template(&template), + literal_pct: false, + }; + } + } + } + } + Hint::None +} + +/// Collapse runs of whitespace, keeping the original case (the template is +/// also what the group is named after). +fn normalize_template(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Recognise the 4D groups of a series list. +/// +/// Series are bucketed by (study, modality); within a bucket, series whose +/// descriptions carry a percent phase are grouped by their description +/// template, and series with a TemporalPositionIdentifier but no percent +/// are grouped by identical description. A group needs at least three +/// phases — two series with "50%" in the name are more likely a coincidence +/// than an acquisition. Average / MIP / MinIP reconstructions of the bucket +/// are attached to its first group. +pub fn detect(series: &[SeriesInfo]) -> Vec { + // (study_uid, modality) buckets, in first-seen order. + let mut buckets: Vec<(String, String, Vec)> = Vec::new(); + for (i, s) in series.iter().enumerate() { + match buckets + .iter_mut() + .find(|(st, m, _)| *st == s.study_uid && *m == s.modality) + { + Some((_, _, v)) => v.push(i), + None => buckets.push((s.study_uid.clone(), s.modality.clone(), vec![i])), + } + } + + let mut out = Vec::new(); + for (study_uid, modality, idxs) in buckets { + // Percent-tagged series by template, in first-seen order. + // (template, written as a literal percent, [(series index, number)]). + type Template = (String, bool, Vec<(usize, f32)>); + let mut templates: Vec