From 5c227b96268f6f56eb3252aa77ebd7bbb7fc859b Mon Sep 17 00:00:00 2001 From: alexprotom Date: Sun, 30 Aug 2026 14:11:43 +0200 Subject: [PATCH 1/5] Added local PACS --- README.md | 15 +- docs/README.md | 1 + docs/architecture.md | 32 ++- docs/export-and-tools.md | 12 +- docs/pacs.md | 150 +++++++++++ docs/viewer.md | 5 +- src/app/chrome.rs | 16 ++ src/app/dialogs.rs | 1 + src/app/mod.rs | 34 +++ src/app/pacs_win.rs | 459 ++++++++++++++++++++++++++++++++++ src/archive.rs | 524 +++++++++++++++++++++++++++++++++++++++ src/dicom_export.rs | 307 +++++++++++++++-------- src/lib.rs | 1 + src/settings.rs | 18 ++ tests/archive.rs | 191 ++++++++++++++ 15 files changed, 1656 insertions(+), 110 deletions(-) create mode 100644 docs/pacs.md create mode 100644 src/app/pacs_win.rs create mode 100644 src/archive.rs create mode 100644 tests/archive.rs diff --git a/README.md b/README.md index 479ca1b..909426b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ holds the registration controls and both dataset trees.* converting as they cross), renaming at every level from patient down to a single segment, six-view comparison mode with patient-space crosshair linking. +* **Patient archive** - a local PACS: a store on disk where every study you + file lives between sessions, listed patient by patient without opening a + single DICOM file, taken into either dataset with one button, and given back + the structures and segmentations you drew on it. Uploads are **derived + objects only** - new RTSTRUCT and SEG instances carrying the original Study + and Frame of Reference UIDs, so they file themselves under the study they + belong to and the images are never re-sent. The layout is folders and files + named by their DICOM identifiers with plain-text sidecars, so any other + application can read it and nothing is locked in. * **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 @@ -114,11 +123,12 @@ holds the registration controls and both dataset trees.* 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 + (CT + RTSTRUCT + SEG + RTDOSE + RTPLAN), the **patient archive** window, 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 + RT-study generator; 280+ tests across twelve integration suites assert the whole stack against an analytically known phantom, on Linux and Windows in CI. @@ -186,6 +196,7 @@ ships a real two-phase 4DCT (see | [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/auto-segmentation.md](docs/auto-segmentation.md) | The pure-Rust TotalSegmentator: models, pipeline, engines, validation, classes, licensing | +| [docs/pacs.md](docs/pacs.md) | The local patient archive: the window, the 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/example-data.md](docs/example-data.md) | Bundled patient data, source and citations | diff --git a/docs/README.md b/docs/README.md index 78b1bc6..c791527 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ covers one area in depth. | [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 | +| [pacs.md](pacs.md) | The local patient archive: the PACS window, the on-disk layout and its sidecars, filing a folder, taking a patient into a dataset, sending structures and segmentations back | | [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 | diff --git a/docs/architecture.md b/docs/architecture.md index 0de3639..31ba1cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,10 +58,10 @@ rust-dicom-station │ │ 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, +│ │ model manager, structure propagation, DRR, PACS, export, anonymizer, │ │ test-data generator (one shared skeleton) │ ├── Background jobs: one progress handle, one poll loop -│ ├── Settings: theme, model folder (viewer_settings.txt) +│ ├── Settings: theme, model folder, archive folder (viewer_settings.txt) │ └── Theme: dark / light / system, accent colors │ ├── DICOM @@ -79,7 +79,11 @@ rust-dicom-station │ │ │ 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 +│ ├── Anonymizer: scan, review every identifying tag, rewrite with consistent UID remap +│ └── Patient archive: a local store filed patient ▶ study ▶ instance with text +│ sidecars, import (copy, dedupe by SOP UID), listing without opening a file, +│ loading a study into a dataset, and derived objects (RTSTRUCT, SEG) sent +│ back under the original Study and Frame of Reference UIDs │ ├── Data simulation │ ├── Synthetic RT phantom study (CT, RTSTRUCT, RTDOSE, RTPLAN, DX, RTIMAGE, REG, RTRECORD) @@ -141,7 +145,7 @@ rust-dicom-station │ ├── 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 +├── Tests: 12 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) ├── Installer: Windows setup (shortcuts, VC++ runtime, optional weight prefetch, uninstall) @@ -183,7 +187,10 @@ src/ 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 + settings.rs persisted preferences (theme, model folder, archive folder) App + archive.rs the local patient archive: the on-disk layout and its text + sidecars, scanning, importing (copy + dedupe by SOP UID), + rebuilding an index from headers, removal DICOM app/ egui application, split by concern; every submodule is a further `impl ViewerApp` block, so the struct and all its @@ -213,6 +220,8 @@ src/ models_win.rs the model manager window propagate_win.rs structure propagation window and worker drr_win.rs the DRR window: geometry, projectors, comparison + pacs_win.rs the PACS window: the archive root, the patient / study list, + import, load into a dataset, send the derived objects back 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 @@ -345,7 +354,7 @@ src/ engine.rs backend choice, the encoded-slice cache, the one call the user interface makes -tests/ nine integration suites (see Testing) +tests/ twelve integration suites (see Testing) examples/ autoseg_cli, autoseg_probe, segvol_cli, segvol_probe, medsam2_cli, medsam2_probe; common/ holds what they share tools/ gen_reference_activations.py, gen_ops_fixtures.py — the @@ -513,7 +522,7 @@ against it), with the wgpu backend added by the cargo feature `gpu` ## Testing -Nine integration suites plus in-module unit tests run against the same +Twelve 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 @@ -528,6 +537,15 @@ code paths the GUI uses, with no external data or tooling: meshing; * **anonymize** — anonymize → reload: identity gone, references intact, pixels byte-identical; +* **dicomseg** — segments built in memory → SEG written → reloaded by the + ordinary directory scanner → the masks back voxel for voxel, including the + sub-grid the frame positions produce; +* **body** — the body / EXTERNAL contour on phantoms carrying couch, chair + and mask, against the area the analytic phantom prescribes; +* **archive** — the whole archive round trip: file the phantom study, list it + 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 under the same Study Instance UID; * **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 diff --git a/docs/export-and-tools.md b/docs/export-and-tools.md index b724a66..aa6a394 100644 --- a/docs/export-and-tools.md +++ b/docs/export-and-tools.md @@ -6,8 +6,12 @@ generating a fully synthetic RT study for testing. (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.) +[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 store the application +keeps for itself. The two are the same DICOM writer underneath — see +[pacs.md](pacs.md) for when to reach for which. ## DICOM export @@ -47,6 +51,10 @@ 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…*. +To write only what was *drawn* — the structure sets and segmentation series, +with the images left where they already are — use *📤 Send dataset* in the +[patient archive](pacs.md) window instead of a full export. + The exports round-trip through this viewer and pydicom; they are QA/research objects, not guaranteed-complete clinical IODs. diff --git a/docs/pacs.md b/docs/pacs.md new file mode 100644 index 0000000..bbae454 --- /dev/null +++ b/docs/pacs.md @@ -0,0 +1,150 @@ +# 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, ready +to be taken into a dataset and given back the structures and segmentations +drawn on them. + +It is a PACS in the sense that matters at a workstation — a persistent place +where patients live between sessions, separate from whatever folder the data +originally came off — and not in the sense of DICOM networking. There is no +listener, no association negotiation, no C-FIND, C-MOVE or C-STORE, and +nothing on the network at all: the archive is a folder on disk that this +application owns. Everything below follows from that choice. + +## 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)` — that expands into its studies, newest first, each described +as `20260827 — Planning · CT, RTSTRUCT, SEG · 214 files`. Selecting a patient +row means the whole patient; selecting a study row means that study. The +right-click menu on either row removes it from the archive. + +The archive folder is shown at the top and can be pointed anywhere — an +external drive, a network share the operating system has already mounted. 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 the choice 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 look at +the archive with a file manager, copy a study folder onto a stick, or hand it +to another DICOM application, and nothing is lost — which is the property a +proprietary index would have taken away. + +The sidecars exist for one reason: listing the archive must stay instant +however large it grows, and reading headers out of ten thousand files is not +instant. They are a cache and 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 is fast from then on; delete every sidecar and the +archive rebuilds itself. + +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, which merges +two patients who already shared an identifier — the correct reading — and is +the reason the folder name is never the authority. `PATIENT.txt` is. + +## Filing + +Import copies; it never moves, so importing does not take the source folder +apart. 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 a +duplicate and is left alone, so importing the same folder twice is a no-op +rather than a second copy, 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 rather than per +file — the counts and the modality list are only right once everything is in. + +## Taking a patient into the viewer + +There is no special path: **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, which is how a planning CT and a later verification +CT end up in one dataset. + +This is deliberately unremarkable. Anything the archive could do that the +ordinary loader could not would be a second import path to keep correct. + +## 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 and, worse, create a second copy of +the same anatomy under new Instance UIDs. + +Each written object gets + +* a **fresh SOP Instance UID and Series Instance UID** — it is a new object, + not a claim to replace one, which is what keeps the archive append-only and + a mistaken upload harmless; +* the **original Study Instance UID**, taken from the object itself where it + says so and from the series it references otherwise — this is what files it + under the patient and study it belongs to rather than beside them; +* 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, as a cross-reference to + data already in the archive. + +The objects are written to a scratch folder and then imported through the +ordinary import path, so the filing rule lives in exactly one place and an +upload that fails half way leaves the archive untouched rather than +half-written. The scratch folder is removed afterwards. + +Because every send creates new instances, sending the same dataset twice +leaves two structure sets in the study rather than overwriting one. That is +the honest behaviour for an archive — a record of what was drawn, when — and +the unwanted one can be removed from the study through the data tree, or the +study's older objects removed 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 it, file it, list it from the +sidecars alone, load the archived folder back through the ordinary loader, +draw a segmentation on it, send the derived objects back, and assert that they +joined the same patient and the same study — no second patient, no second +study, the Study Instance UID unchanged, the file count grown by exactly the +number of objects written, `SEG` now among the study's modalities, and the +segmentation present again when the study is reloaded. Removing the patient +empties the archive. + +The unit tests in `src/archive.rs` cover the parts that have no round trip: +the folder-name sanitizer against what acquiring systems actually write, a +missing root reading as an empty archive rather than an error, a study reading +back from its sidecar, and `remove` refusing any path outside the archive root. diff --git a/docs/viewer.md b/docs/viewer.md index 8e6d6ba..f6cefa3 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -115,7 +115,10 @@ 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. +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 an ordinary +DICOM folder, so it takes the same path. The left panel shows each dataset as a full DICOM hierarchy: diff --git a/src/app/chrome.rs b/src/app/chrome.rs index 264e319..4815a85 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; @@ -275,6 +276,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 +376,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/dialogs.rs b/src/app/dialogs.rs index 1f89b2a..6fe6f19 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); diff --git a/src/app/mod.rs b/src/app/mod.rs index d6b4b95..ba2c12f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -41,6 +41,7 @@ mod dialogs; mod drr_win; mod jobs; mod models_win; +mod pacs_win; mod panels; mod planar; mod prompt_seg; @@ -55,6 +56,7 @@ 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}; @@ -851,6 +853,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. @@ -942,6 +953,11 @@ impl ViewerApp { .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 +1049,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, @@ -1098,9 +1117,17 @@ 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, }) { @@ -1269,6 +1296,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), diff --git a/src/app/pacs_win.rs b/src/app/pacs_win.rs new file mode 100644 index 0000000..3f0349d --- /dev/null +++ b/src/app/pacs_win.rs @@ -0,0 +1,459 @@ +//! *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"); + + egui::Window::new("🏥 PACS — patient archive") + .id(egui::Id::new("pacs_window")) + .open(&mut open) + .resizable(true) + .default_size([720.0, 520.0]) + .show(ctx, |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/archive.rs b/src/archive.rs new file mode 100644 index 0000000..e2361d9 --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,524 @@ +//! 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() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// 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/dicom_export.rs b/src/dicom_export.rs index 8f2792b..ef7b46a 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; } @@ -1054,3 +1069,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/lib.rs b/src/lib.rs index b6b4f27..249146b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod anonymize; pub mod app; +pub mod archive; pub mod autoseg; pub mod bodymask; pub mod dicom_export; diff --git a/src/settings.rs b/src/settings.rs index d66bd8e..5791aa7 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,6 +9,9 @@ const APP_NAME: &str = "RustDICOMStation"; /// Settings key of the model root; the installer writes it too. pub const MODELS_DIR_KEY: &str = "models_dir"; +/// Settings key of the patient archive root. +pub const ARCHIVE_DIR_KEY: &str = "archive_dir"; + /// Settings keys of the two optional side-panel modules. const MODULE_REG_KEY: &str = "module_image_registration"; const MODULE_SIM_KEY: &str = "module_image_simulation"; @@ -25,6 +28,12 @@ pub struct Settings { /// [`default_models_dir`]. pub models_dir: Option, + /// Root of the local patient archive. + /// + /// `None` means the platform-specific default, + /// [`crate::archive::default_root`]. + pub archive_dir: Option, + /// *Modules ▶ Image registration*: the registration section is shown in /// the side panel. pub module_registration: bool, @@ -41,6 +50,7 @@ impl Default for Settings { Settings { theme: ThemePreference::Dark, models_dir: None, + archive_dir: None, // Both optional modules start hidden; the Modules menu turns them // on and the choice is remembered. module_registration: false, @@ -250,6 +260,11 @@ fn parse(text: &str) -> Settings { if !v.is_empty() { s.models_dir = Some(PathBuf::from(v)); } + } else if key.eq_ignore_ascii_case(ARCHIVE_DIR_KEY) { + let v = value.trim(); + if !v.is_empty() { + s.archive_dir = Some(PathBuf::from(v)); + } } else if key.eq_ignore_ascii_case(MODULE_REG_KEY) { if let Some(b) = bool_from_str(value) { s.module_registration = b; @@ -273,6 +288,9 @@ fn render(s: &Settings) -> String { if let Some(dir) = &s.models_dir { out.push_str(&format!("{MODELS_DIR_KEY} = {}\n", dir.display())); } + if let Some(dir) = &s.archive_dir { + out.push_str(&format!("{ARCHIVE_DIR_KEY} = {}\n", dir.display())); + } out.push_str(&format!( "# optional side-panel modules (Modules menu) = on | off\n\ {MODULE_REG_KEY} = {}\n\ diff --git a/tests/archive.rs b/tests/archive.rs new file mode 100644 index 0000000..f9367d0 --- /dev/null +++ b/tests/archive.rs @@ -0,0 +1,191 @@ +//! The local archive, end to end: file a study into it, list it without +//! opening a DICOM file, load it back into a dataset the way the PACS window +//! does, draw something on it, and send the derived objects back so they land +//! in the same patient and study rather than beside them. +//! +//! This is the whole round trip the archive exists for, so it is tested as +//! one path rather than as four isolated calls. + +use rust_dicom_station::archive::Archive; +use rust_dicom_station::dicom_export::{self, ExportParams}; +use rust_dicom_station::dicomseg::SegSeries; +use rust_dicom_station::gen_test_data::{self, GenParams}; +use rust_dicom_station::loader; +use rust_dicom_station::progress::Progress; +use rust_dicom_station::segmentation::Segmentation; + +fn scratch(tag: &str) -> std::path::PathBuf { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("target/{tag}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// A solid ellipsoid around the volume centre — something with a non-zero +/// voxel count, which is what makes the writer emit the object at all. +fn ball(dims: [usize; 3], radius: f64) -> Vec { + let [nx, ny, nz] = dims; + let c = [ + (nx as f64 - 1.0) * 0.5, + (ny as f64 - 1.0) * 0.5, + (nz as f64 - 1.0) * 0.5, + ]; + let mut m = vec![0u8; nx * ny * nz]; + for k in 0..nz { + for j in 0..ny { + for i in 0..nx { + let d = ((i as f64 - c[0]).powi(2) + + (j as f64 - c[1]).powi(2) + + (k as f64 - c[2]).powi(2)) + .sqrt(); + if d <= radius { + m[k * nx * ny + j * nx + i] = 1; + } + } + } + } + m +} + +#[test] +fn a_study_files_lists_loads_and_takes_back_what_was_drawn_on_it() { + let src = scratch("test_archive_src"); + let written = gen_test_data::generate(&src, &GenParams::default(), &Progress::default()) + .expect("test data generation succeeds"); + + let root = scratch("test_archive_root"); + let archive = Archive::new(&root); + + // ---- file it -------------------------------------------------------- + let sum = archive + .import(&src, &Progress::default()) + .expect("import succeeds"); + assert_eq!(sum.stored, written, "every generated file was filed"); + assert_eq!(sum.skipped, 0, "everything the generator wrote is DICOM"); + assert_eq!(sum.duplicates, 0); + assert_eq!( + (sum.patients, sum.studies), + (1, 1), + "one patient, one study" + ); + + // ---- list it -------------------------------------------------------- + let patients = archive.scan().expect("scan succeeds"); + assert_eq!(patients.len(), 1); + let p = &patients[0]; + assert_eq!(p.title(), "PHANTOM RT (RTTEST001)", "the listed identity"); + assert_eq!(p.studies.len(), 1); + let entry = p.studies[0].clone(); + assert_eq!(entry.files, written, "the sidecar counts what is there"); + assert!( + entry.modalities.iter().any(|m| m == "CT") + && entry.modalities.iter().any(|m| m == "RTSTRUCT"), + "the sidecar lists the modalities it holds: {:?}", + entry.modalities + ); + let line = entry.describe(); + assert!( + line.contains("CT") && line.ends_with(&format!("{written} files")), + "the one-line description the window shows: {line}" + ); + + // Importing the same folder again must be a no-op, not a second copy. + let again = archive + .import(&src, &Progress::default()) + .expect("re-import succeeds"); + assert_eq!( + (again.stored, again.duplicates), + (0, written), + "the same SOP Instance UIDs are recognised" + ); + + // ---- take it into a dataset ----------------------------------------- + // The PACS window loads an archived study folder with the ordinary + // directory scanner, so that is exactly what is asserted here. + let mut study = + loader::load_directory(&entry.dir, &Progress::default()).expect("the archived study loads"); + assert!(!study.series.is_empty(), "the CT came back"); + assert_eq!( + study.series[study.active_series].study_uid, entry.study_uid, + "the loaded study is the one the archive listed" + ); + let structs_before = study.structure_sets.len(); + + // ---- draw on it ----------------------------------------------------- + let dims = study.volume.dims; + let mut ser = SegSeries::new( + "Archive QA".into(), + study.volume.grid(), + study.series[study.active_series].uid.clone(), + study.series[study.active_series].study_uid.clone(), + ); + ser.segs.push(Segmentation::from_mask( + "Ball".into(), + [220, 40, 40], + dims, + ball(dims, 8.0), + )); + study.seg_series.push(ser); + + // ---- send it back --------------------------------------------------- + let derived = scratch("test_archive_derived"); + let params = ExportParams::for_study(&study); + let n = dicom_export::export_derived(&study, &derived, ¶ms, &Progress::default()) + .expect("the derived export succeeds"); + assert_eq!( + n, + structs_before + 1, + "one file per structure set plus the new segmentation series, and nothing else" + ); + for f in std::fs::read_dir(&derived).unwrap().filter_map(|e| e.ok()) { + let name = f.file_name().to_string_lossy().to_string(); + assert!( + name.starts_with("RS_") || name.starts_with("SEG_"), + "no image data is re-sent, found {name}" + ); + } + + let up = archive + .import(&derived, &Progress::default()) + .expect("upload succeeds"); + assert_eq!(up.stored, n, "the derived objects are new instances"); + assert_eq!( + (up.patients, up.studies), + (1, 1), + "they land in one patient and one study — the ones already there" + ); + + let patients = archive.scan().expect("rescan succeeds"); + assert_eq!(patients.len(), 1, "no second patient was invented"); + let p = &patients[0]; + assert_eq!(p.studies.len(), 1, "no second study was invented"); + assert_eq!( + p.studies[0].study_uid, entry.study_uid, + "the Study Instance UID is kept, which is what files them together" + ); + assert_eq!( + p.studies[0].files, + written + n, + "the archive grew by the derived objects" + ); + assert!( + p.studies[0].modalities.iter().any(|m| m == "SEG"), + "the segmentation is now part of the study: {:?}", + p.studies[0].modalities + ); + + // And the study reads back with what was drawn on it. + let back = loader::load_directory(&p.studies[0].dir, &Progress::default()) + .expect("the enriched study loads"); + assert_eq!(back.seg_series.len(), 1, "the segmentation came back"); + assert_eq!(back.seg_series[0].segs.len(), 1); + assert_eq!(back.seg_series[0].segs[0].name, "Ball"); + assert_eq!( + back.structure_sets.len(), + structs_before * 2, + "the re-sent structure set is a new instance beside the original" + ); + + // ---- and it can be taken out again ---------------------------------- + archive.remove(&p.dir).expect("removing a patient succeeds"); + assert!(archive.scan().expect("scan succeeds").is_empty()); +} From f1a064021099f8e114b6c36e66b7febfccd7b640 Mon Sep 17 00:00:00 2001 From: alexprotom Date: Sun, 30 Aug 2026 23:02:46 +0200 Subject: [PATCH 2/5] fix: segmentation engine rework --- README.md | 9 + docs/README.md | 1 + docs/architecture.md | 25 +- docs/segmentation.md | 3 +- docs/structure-algebra.md | 144 ++++++++ examples/body_cli.rs | 10 +- src/app/body_win.rs | 14 +- src/app/chrome.rs | 13 +- src/app/combine_win.rs | 726 ++++++++++++++++++++++++++++++++++++++ src/app/dialogs.rs | 1 + src/app/mod.rs | 22 +- src/app/panels.rs | 24 +- src/app/seg_engines.rs | 16 +- src/app/sets.rs | 13 + src/autoseg/mod.rs | 20 +- src/bodymask.rs | 43 ++- src/lib.rs | 1 + src/morphology.rs | 429 +++++++++++++++++++--- src/nn/tensor.rs | 64 +++- src/structops.rs | 674 +++++++++++++++++++++++++++++++++++ src/volume.rs | 106 +++--- tests/body.rs | 7 +- tests/structops.rs | 290 +++++++++++++++ 23 files changed, 2508 insertions(+), 147 deletions(-) create mode 100644 docs/structure-algebra.md create mode 100644 src/app/combine_win.rs create mode 100644 src/structops.rs create mode 100644 tests/structops.rs diff --git a/README.md b/README.md index 909426b..c695ca3 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,14 @@ holds the registration controls and both dataset trees.* 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). +* **Structure algebra** - union, intersection, subtraction and symmetric + difference over any mix of RT structures and segmentations, with a margin + on any operand and on the result. Margins are given in **patient** + directions - six of them, as an exact ellipsoid - so "8 mm superiorly" + means the same on an axial CT and a feet-first MR. A crop is an + intersection with a shrunken operand, a ring is the difference of two + expansions, and the recipe is printed as one line so a subtraction the + wrong way round is visible before it runs. * **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 @@ -192,6 +200,7 @@ ships a real two-phase 4DCT (see | [docs/propagation.md](docs/propagation.md) | Carrying contours and segmentations across a registration | | [docs/drr.md](docs/drr.md) | Digitally reconstructed radiographs: the two projectors and the geometry | | [docs/segmentation.md](docs/segmentation.md) | Brush / eraser / region growing, 3D view, mask → RTSTRUCT | +| [docs/structure-algebra.md](docs/structure-algebra.md) | Combining structures: boolean operations, margins, cropping, cleanup | | [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 | diff --git a/docs/README.md b/docs/README.md index c791527..f1d724a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ covers one area in depth. | [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 | +| [structure-algebra.md](structure-algebra.md) | Combining structures and segmentations: union / intersection / subtraction / symmetric difference, margins in patient directions, cropping, cleanup, and how contours and masks are made interchangeable | | [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 | diff --git a/docs/architecture.md b/docs/architecture.md index 31ba1cf..600e2da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,6 +115,10 @@ rust-dicom-station │ │ 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) +│ ├── Structure algebra: union / intersection / subtraction / symmetric +│ │ difference over any mix of contours and segments, a margin per operand +│ │ and on the result (six patient directions, exact ellipsoids), crop, +│ │ fill / smooth / prune, out as either kind │ ├── 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 @@ -226,6 +230,9 @@ src/ 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 + combine_win.rs the structure-algebra window: the ordered operand list, + per-operand and per-direction margins, the recipe line, + and landing the answer as a segment or a contour 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 @@ -271,10 +278,14 @@ src/ 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 + anisotropic Euclidean distance transform (two-sided and + one-sided) and the erode / dilate / open / close it + powers, including per-direction ellipsoidal margins, + 6-connected components, slice-wise and 3-D hole filling, + the extruded-equipment test, box-blur smoothing Core + structops.rs structure algebra: the four boolean operations, margins + in patient directions (per-direction ellipsoids), crop, + fill / smooth / prune, over masks on one lattice Seg bodymask.rs the body / EXTERNAL contour: foreground by modality (HU, or bias-flattened MR), equipment removal, component selection, thin-anatomy recovery, filling — classically @@ -390,13 +401,13 @@ 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 +Body contouring, structure algebra, 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 +* 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…`); diff --git a/docs/segmentation.md b/docs/segmentation.md index 4498b31..a0aad4e 100644 --- a/docs/segmentation.md +++ b/docs/segmentation.md @@ -5,7 +5,8 @@ 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. +masks described here. Combining structures with each other is +[structure-algebra.md](structure-algebra.md). ## Segmentation masks diff --git a/docs/structure-algebra.md b/docs/structure-algebra.md new file mode 100644 index 0000000..ad8e7b2 --- /dev/null +++ b/docs/structure-algebra.md @@ -0,0 +1,144 @@ +# Combining structures and segmentations + +Union, intersection, subtraction and symmetric difference over any mix of RT +structures and segmentations, with 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 instead of by hand. + +## 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)`. None of these is difficult and all of them are tedious, easy +to get backwards, and impossible to check afterwards if you cannot see what +was combined with what. + +So the tool does two things beyond the arithmetic. It keeps the operand list +**ordered and visible**, with ↑ ↓ arrows, because three of the four +operations are not commutative in the way people assume. And it prints the +recipe as one line above the buttons — + +``` +PTV_eval = PTV ∩ (BODY -5.0 mm) +``` + +— which is the cheapest possible guard against the mistake this tool exists +to make 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 +stores voxels on a lattice. Which one you happen to have should not decide +what you can do with it, so 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`, a segment already on this one not at all — and +the result goes back out as whichever kind you ask for. Mixing them is the +normal case, not a special one. + +That also means the answer is a **voxel** answer, on the displayed series' +grid. Choosing *an RT structure* as the output converts it back with the +usual marching-squares walk, so the contour you get is 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, and the smoothing +option exists for that. + +## Using it + +*Tools ▶ ◧ Combine structures in dataset A…*, the **◧ Combine** button in +the sidebar, or — usually the quickest — tick the structures you want in the +data tree, right-click and choose **◧ Combine …**, which opens the window +with them already listed in the order they were 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 to it *before* it is combined. This is what makes the tool + expressive rather than merely convenient: a crop is an intersection whose + second operand was shrunk first, and a ring is 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 is what 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" has to mean the same thing on an axial CT, a coronal MR +and an obliquely acquired series. So a margin is given as six numbers in +**patient** directions — right, left, anterior, posterior, superior, +inferior — and the direction cosines decide which array axis each one is and +which way along it. A series stored feet-first grows toward the head just +the same; there is a test that 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, so a margin is +the same in millimetres along every axis whatever the slice thickness, and +its cost does not depend on how big it is. The asymmetric case is the union +of the eight octants of the shape — dilation distributes over a union of +structuring elements — and each octant is reached by three *one-sided* +passes of the same transform, restricted to sources on one side. + +Erosion is the complement of dilating the complement, which inherits 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 a + structure from the other one is what + [propagation](propagation.md) is for, and doing it silently inside an + algebra tool 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 subtraction with one operand, or an + operand that rasterizes to nothing on the displayed series is refused with + a message rather than quietly dropped from the recipe — because a recipe + missing one of its terms 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 rest of 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 its volume, on a deliberately +non-convex L-shaped union), a superior margin on a feet-first lattice, a +subtraction the wrong way round 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 straight 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/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/src/app/body_win.rs b/src/app/body_win.rs index 2bcb52c..390e779 100644 --- a/src/app/body_win.rs +++ b/src/app/body_win.rs @@ -467,6 +467,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 +489,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 +530,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/chrome.rs b/src/app/chrome.rs index 4815a85..cd49b55 100644 --- a/src/app/chrome.rs +++ b/src/app/chrome.rs @@ -187,8 +187,14 @@ impl ViewerApp { }); 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] = [ + // the same five entries, in the same order, for A and B. + let tools: [(&ToolInfo, &str); 5] = [ + ( + &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 \ @@ -235,6 +241,9 @@ 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 == BODY_CONTOUR.glyph => { self.open_body_dialog(slot) } diff --git a/src/app/combine_win.rs b/src/app/combine_win.rs new file mode 100644 index 0000000..50ac517 --- /dev/null +++ b/src/app/combine_win.rs @@ -0,0 +1,726 @@ +//! 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 std::sync::Arc; + +use crate::progress::Progress; +use crate::segmentation; +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; + egui::Window::new(COMBINE.title(slot)) + .id(egui::Id::new("combine_window")) + .collapsible(true) + .resizable(false) + .default_width(470.0) + .open(&mut open) + .show(ctx, |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/dialogs.rs b/src/app/dialogs.rs index 6fe6f19..b2b3a00 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -50,6 +50,7 @@ impl ViewerApp { self.autoseg_run_window(ctx); self.segvol_window(ctx); self.body_window(ctx); + self.combine_window(ctx); self.medsam2_window(ctx); self.autoseg_result_window(ctx); if let Some(msg) = self.notice.clone() { diff --git a/src/app/mod.rs b/src/app/mod.rs index ba2c12f..700c48c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,7 @@ use crate::volume::{ViewPlane, Volume}; mod body_win; mod box_seg; mod chrome; +mod combine_win; mod d3; mod dialogs; mod drr_win; @@ -559,7 +560,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, @@ -638,6 +639,11 @@ enum ItemAction { from: SetRef, idx: usize, }, + /// Open the structure-algebra window with these items as its operands. + Combine { + from: SetRef, + items: Vec, + }, /// Write these segments as a DICOM SEG file of their own. ExportSeg { from: SetRef, @@ -889,6 +895,11 @@ pub struct ViewerApp { /// The tool window, when open; it stays open across runs. body_dialog: Option, + // Structure algebra (see `structops`): combining contours and segments. + combine_job: Option>, + combine_slot: usize, + combine_dialog: Option, + // Prompt-driven segmentation (SegVol re-implementation, see `segvol`). segvol_job: Option>, segvol_slot: usize, @@ -1065,6 +1076,10 @@ impl ViewerApp { body_slot: 0, body_dialog: None, + combine_job: None, + combine_slot: 0, + combine_dialog: None, + segvol_job: None, segvol_slot: 0, segvol_dialog: None, @@ -1278,6 +1293,11 @@ 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); + } // Poll background registration. if let Some((fixed_slot, out)) = diff --git a/src/app/panels.rs b/src/app/panels.rs index 93278b4..f524ab3 100644 --- a/src/app/panels.rs +++ b/src/app/panels.rs @@ -675,6 +675,21 @@ 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 from.kind == SetKind::Segmentations { ui.separator(); if ui @@ -1050,7 +1065,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 +1259,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), diff --git a/src/app/seg_engines.rs b/src/app/seg_engines.rs index 4ea9a97..906b699 100644 --- a/src/app/seg_engines.rs +++ b/src/app/seg_engines.rs @@ -52,6 +52,12 @@ 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", +}; impl ToolInfo { /// `🤖 Auto-segmentation — dataset A`, the window title. @@ -174,6 +180,13 @@ 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)); + } None } } @@ -307,10 +320,11 @@ mod tests { PROMPT_SEG.glyph, SLICE_PROP.glyph, BODY_CONTOUR.glyph, + COMBINE.glyph, ]; glyphs.sort(); glyphs.dedup(); - assert_eq!(glyphs.len(), 4, "every tool has its own glyph"); + assert_eq!(glyphs.len(), 5, "every tool has its own glyph"); } #[test] diff --git a/src/app/sets.rs b/src/app/sets.rs index dd4e782..daa5f00 100644 --- a/src/app/sets.rs +++ b/src/app/sets.rs @@ -309,6 +309,19 @@ 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::Transfer { from, items, 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/bodymask.rs b/src/bodymask.rs index 7c23d34..d4591c2 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. Only how many there are is reported, +/// but a piece knows its own size so that a caller can say which. #[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/lib.rs b/src/lib.rs index 249146b..2e45617 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,4 +29,5 @@ pub mod segmentation; pub mod segvol; pub mod settings; pub mod simulate; +pub mod structops; pub mod volume; diff --git a/src/morphology.rs b/src/morphology.rs index 2e13d1a..c8ceb66 100644 --- a/src/morphology.rs +++ b/src/morphology.rs @@ -53,12 +53,27 @@ pub fn dist2_to_foreground(mask: &[u8], dims: [usize; 3], spacing: [f64; 3]) -> /// Three separable passes of the 1-D squared-distance transform. fn edt_in_place(f: &mut [f32], dims: [usize; 3], spacing: [f64; 3]) { for (axis, step) in spacing.iter().enumerate() { - pass_along(f, dims, axis, *step as f32); + pass_along(f, dims, axis, *step as f32, Sweep::Both); } } -/// Stride and length of the lines running along `axis`, and the start index -/// of each line. +/// Which sources a pass is allowed to reach back to. +/// +/// `Both` is the ordinary distance transform. The one-sided forms are what +/// make an *asymmetric* margin possible: restricted to sources at a lower +/// index, a pass measures only how far the mask has grown in the `+axis` +/// direction, so three such passes give the distance within one octant and +/// eight octants tile the whole neighbourhood — each with its own radius. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Sweep { + Both, + /// Sources at a lower index only (growth toward `+axis`). + Forward, + /// Sources at a higher index only (growth toward `−axis`). + Backward, +} + +/// Length, stride and the start index of every line running along `axis`. fn lines_along(dims: [usize; 3], axis: usize) -> (usize, usize, Vec) { let [nx, ny, nz] = dims; match axis { @@ -74,76 +89,138 @@ fn lines_along(dims: [usize; 3], axis: usize) -> (usize, usize, Vec) { } } +/// Per-thread scratch for the 1-D transform, so that a pass over a whole CT +/// allocates a handful of buffers rather than one per line. +struct Envelope { + d: Vec, + v: Vec, + z: Vec, + out: Vec, +} + +impl Envelope { + fn new(n: usize) -> Envelope { + Envelope { + d: vec![0.0; n], + v: vec![0; n], + z: vec![0.0; n + 1], + out: vec![0.0; n], + } + } +} + +/// A pointer to the buffer, handed to each rayon task. Lines along one axis +/// are disjoint sets of indices, so writing them in parallel is sound; the +/// alternative — collecting every line and writing back afterwards — costs a +/// second copy of the volume and one allocation per line, which on a routine +/// CT is 300 MB and 150 000 allocations per pass. +#[derive(Clone, Copy)] +struct LinePtr(*mut f32); +unsafe impl Send for LinePtr {} +unsafe impl Sync for LinePtr {} +impl LinePtr { + /// Reached through a method, not the field: closure capture in Rust 2021 + /// is field-precise, and capturing the bare `*mut f32` would sidestep the + /// `Sync` promise made just above. + fn get(self) -> *mut f32 { + self.0 + } +} + /// The 1-D lower envelope of parabolas along one axis, in place. -fn pass_along(f: &mut [f32], dims: [usize; 3], axis: usize, step: f32) { +/// +/// Felzenszwalb & Huttenlocher's algorithm: every position contributes a +/// parabola `f(q) + h²(p − q)²`, and their lower envelope is built in one +/// left-to-right sweep and read off in another. `Sweep::Forward` reads each +/// value off the moment its own parabola has been inserted, which is exactly +/// the minimum over sources at or below that index. +fn pass_along(f: &mut [f32], dims: [usize; 3], axis: usize, step: f32, sweep: Sweep) { let (n, stride, starts) = lines_along(dims, axis); - if n == 0 { + if n == 0 || !step.is_finite() || step <= 0.0 { return; } let sq = step * step; - // Lines along an axis are disjoint, so each can be lifted out, solved - // and written back independently. - let out: Vec<(usize, Vec)> = starts - .par_iter() - .map(|&base| { - let mut d = vec![0f32; n]; - let mut v = vec![0usize; n]; // parabola centres - let mut z = vec![0f32; n + 1]; // envelope breakpoints - for (q, slot) in d.iter_mut().enumerate() { - *slot = f[base + q * stride]; + let ptr = LinePtr(f.as_mut_ptr()); + starts.par_iter().for_each_init( + || Envelope::new(n), + |e, &base| { + // Gather the line, reversed for a backward sweep so that the + // same forward machinery serves both. + for q in 0..n { + let src = if sweep == Sweep::Backward { + n - 1 - q + } else { + q + }; + e.d[q] = unsafe { *ptr.get().add(base + src * stride) }; } let mut k = 0usize; - v[0] = 0; - z[0] = f32::NEG_INFINITY; - z[1] = f32::INFINITY; + e.v[0] = 0; + e.z[0] = f32::NEG_INFINITY; + e.z[1] = f32::INFINITY; + if sweep != Sweep::Both { + e.out[0] = e.d[0]; + } for q in 1..n { - if d[q].is_infinite() { - // A parabola of infinite height never joins the envelope. - continue; + if e.d[q].is_finite() { + loop { + let p = e.v[k]; + // Where the parabolas rooted at p and q cross. + let s = if e.d[p].is_infinite() { + f32::NEG_INFINITY + } else { + (e.d[q] + sq * (q * q) as f32 - e.d[p] - sq * (p * p) as f32) + / (2.0 * sq * (q as f32 - p as f32)) + }; + if s <= e.z[k] && k > 0 { + k -= 1; + } else { + k += 1; + e.v[k] = q; + e.z[k] = s; + e.z[k + 1] = f32::INFINITY; + break; + } + } } - loop { - let p = v[k]; - // Intersection of the parabolas rooted at p and q. - let s = if d[p].is_infinite() { - f32::NEG_INFINITY + if sweep != Sweep::Both { + // Only parabolas up to q are in the envelope, and q sits + // in its last piece — so this is the one-sided minimum. + let p = e.v[k]; + e.out[q] = if e.d[p].is_infinite() { + f32::INFINITY } else { - (d[q] + sq * (q * q) as f32 - d[p] - sq * (p * p) as f32) - / (2.0 * sq * (q as f32 - p as f32)) + let dq = (q as f32 - p as f32) * step; + e.d[p] + dq * dq }; - if s <= z[k] && k > 0 { - k -= 1; - } else { + } + } + if sweep == Sweep::Both { + // Walk the finished envelope left to right. + let mut k = 0usize; + for (q, slot) in e.out.iter_mut().enumerate() { + while e.z[k + 1] < q as f32 { k += 1; - v[k] = q; - z[k] = s; - z[k + 1] = f32::INFINITY; - break; } + let p = e.v[k]; + *slot = if e.d[p].is_infinite() { + f32::INFINITY + } else { + let dq = (q as f32 - p as f32) * step; + e.d[p] + dq * dq + }; } } - // Walk the envelope left to right. - let mut line = vec![0f32; n]; - let mut k = 0usize; - for (q, slot) in line.iter_mut().enumerate() { - while z[k + 1] < q as f32 { - k += 1; - } - let p = v[k]; - *slot = if d[p].is_infinite() { - f32::INFINITY + for q in 0..n { + let dst = if sweep == Sweep::Backward { + n - 1 - q } else { - let dq = (q as f32 - p as f32) * step; - d[p] + dq * dq + q }; + unsafe { *ptr.get().add(base + dst * stride) = e.out[q] }; } - (base, line) - }) - .collect(); - for (base, line) in out { - for (q, val) in line.into_iter().enumerate() { - f[base + q * stride] = val; - } - } + }, + ); } /// Erosion by a ball of `radius_mm`: the voxels further than the radius from @@ -168,6 +245,111 @@ pub fn dilate_mm(mask: &[u8], dims: [usize; 3], spacing: [f64; 3], radius_mm: f6 d.par_iter().map(|&v| u8::from(v <= r2)).collect() } +/// A margin in millimetres per array axis and per direction: +/// `radii[axis] = [toward decreasing index, toward increasing index]`. +/// +/// The structuring element is the ellipsoid whose semi-axis in each of the +/// six directions is the corresponding entry — the shape a planning system +/// means by "5 mm laterally, 8 mm superiorly". Symmetric cases are detected +/// and take a single distance transform; only a genuinely one-sided margin +/// pays for the eight-octant form. +pub type Radii = [[f64; 2]; 3]; + +/// True when the margin is the same in both directions along every axis, so +/// the structuring element is centrally symmetric. +fn symmetric(radii: &Radii) -> bool { + radii.iter().all(|r| (r[0] - r[1]).abs() < 1e-9) +} + +/// Largest radius anywhere in the margin. +fn widest(radii: &Radii) -> f64 { + radii.iter().flatten().cloned().fold(0.0, f64::max) +} + +/// Scale factor that turns a physical spacing into "fractions of the radius", +/// so that thresholding the transform at 1 tests the ellipsoid equation +/// `Σ (dₐ/rₐ)² ≤ 1`. A radius of zero forbids any offset along that axis, +/// which a very large scale expresses without a special case. +fn unit_step(spacing: f64, radius: f64) -> f32 { + if radius <= 0.0 { + (spacing * 1e6) as f32 + } else { + (spacing / radius) as f32 + } +} + +/// Dilation by the ellipsoid of [`Radii`]. +/// +/// Dilation distributes over a union of structuring elements, and an +/// asymmetric ellipsoid is the union of its eight octants — each of which is +/// an octant of an *ordinary* ellipsoid, and so is reached by three one-sided +/// passes. Eight of those is the price of a one-sided margin; a symmetric one +/// costs a single transform. +pub fn dilate_radii(mask: &[u8], dims: [usize; 3], spacing: [f64; 3], radii: &Radii) -> Vec { + let n = dims[0] * dims[1] * dims[2]; + debug_assert_eq!(mask.len(), n); + if widest(radii) <= 0.0 { + return mask.to_vec(); + } + let seed = || -> Vec { + mask.par_iter() + .map(|&v| if v == 0 { f32::INFINITY } else { 0.0 }) + .collect() + }; + if symmetric(radii) { + let mut f = seed(); + for (axis, sp) in spacing.iter().enumerate() { + pass_along( + &mut f, + dims, + axis, + unit_step(*sp, radii[axis][1]), + Sweep::Both, + ); + } + return f.par_iter().map(|&v| u8::from(v <= 1.0)).collect(); + } + let mut out = vec![0u8; n]; + for octant in 0..8u8 { + let mut f = seed(); + for (axis, sp) in spacing.iter().enumerate() { + // Bit set = this octant grows toward +axis, so its sources lie + // at lower indices and the pass sweeps forward. + let positive = octant >> axis & 1 == 1; + let r = radii[axis][usize::from(positive)]; + let sweep = if positive { + Sweep::Forward + } else { + Sweep::Backward + }; + pass_along(&mut f, dims, axis, unit_step(*sp, r), sweep); + } + out.par_iter_mut() + .zip(f.par_iter()) + .for_each(|(o, &v)| *o |= u8::from(v <= 1.0)); + } + out +} + +/// Erosion by the ellipsoid of [`Radii`] — everything that survives having +/// the shape swept round the inside of the mask. +/// +/// Computed as the complement of dilating the complement, which is the +/// definition; it also inherits the convention that voxels outside the volume +/// are not background, so anatomy truncated by the field of view is not +/// eroded at the cut. +pub fn erode_radii(mask: &[u8], dims: [usize; 3], spacing: [f64; 3], radii: &Radii) -> Vec { + if widest(radii) <= 0.0 { + return mask.to_vec(); + } + let inverted: Vec = mask.par_iter().map(|&v| u8::from(v == 0)).collect(); + // Reflected: eroding the anterior surface by 5 mm is dilating the + // background toward the posterior. + let mirrored: Radii = std::array::from_fn(|a| [radii[a][1], radii[a][0]]); + let grown = dilate_radii(&inverted, dims, spacing, &mirrored); + grown.par_iter().map(|&v| u8::from(v == 0)).collect() +} + /// Opening — erosion then dilation. Equivalently: the union of every ball of /// `radius_mm` that fits entirely inside the mask, so everything thinner /// than twice the radius disappears and everything thicker keeps its exact @@ -219,8 +401,12 @@ impl Component { pub fn cm3(&self, spacing: [f64; 3]) -> f64 { self.voxels.len() as f64 * spacing[0] * spacing[1] * spacing[2] / 1000.0 } - /// Longest side of the bounding box, in millimetres. + /// Longest side of the bounding box, in millimetres. Zero for an empty + /// component, whose bounding box is the uninitialised one. pub fn extent_mm(&self, spacing: [f64; 3]) -> f64 { + if self.voxels.is_empty() { + return 0.0; + } (0..3) .map(|a| (self.hi[a] - self.lo[a] + 1) as f64 * spacing[a]) .fold(0.0, f64::max) @@ -541,9 +727,17 @@ pub fn blur_mm(src: &[f32], dims: [usize; 3], spacing: [f64; 3], sigma_mm: f64) return buf; } for (axis, step) in spacing.iter().enumerate() { + if !step.is_finite() || *step <= 0.0 { + continue; + } // A box of width w has variance (w² − 1)/12; three of them give 3×. let var = sigma_mm * sigma_mm / (step * step) / 3.0; - let w = ((12.0 * var + 1.0).sqrt().round() as usize).max(1) | 1; + // Capped at the extent of the axis: a window wider than the data + // averages the same clamped values over and over, and an unclamped + // width from a degenerate spacing (a series that declares a slice + // thickness of zero) would run for the age of the universe. + let n = dims[axis]; + let w = ((12.0 * var + 1.0).sqrt().round() as usize).clamp(1, 2 * n.max(1) + 1) | 1; if w <= 1 { continue; } @@ -644,6 +838,127 @@ mod tests { } } + /// Brute-force dilation by the asymmetric ellipsoid, straight from the + /// definition: p is in iff some set voxel sits within the shape. + fn brute_dilate(mask: &[u8], dims: [usize; 3], sp: [f64; 3], r: &Radii) -> Vec { + let [nx, ny, nz] = dims; + let mut out = vec![0u8; nx * ny * nz]; + for k in 0..nz { + for j in 0..ny { + for i in 0..nx { + let p = [i, j, k]; + 'search: for kk in 0..nz { + for jj in 0..ny { + for ii in 0..nx { + if mask[kk * nx * ny + jj * nx + ii] == 0 { + continue; + } + let q = [ii, jj, kk]; + let mut sum = 0.0f64; + let mut ok = true; + for a in 0..3 { + let d = (p[a] as f64 - q[a] as f64) * sp[a]; + // d > 0 means p lies at a higher index + // than the source: growth toward +axis. + let rad = if d >= 0.0 { r[a][1] } else { r[a][0] }; + if d == 0.0 { + continue; + } + if rad <= 0.0 { + ok = false; + break; + } + sum += (d / rad).powi(2); + } + if ok && sum <= 1.0 + 1e-9 { + out[k * nx * ny + j * nx + i] = 1; + break 'search; + } + } + } + } + } + } + } + out + } + + #[test] + fn a_directional_margin_matches_the_shape_it_claims() { + let dims = [11, 9, 7]; + let sp = [1.0, 1.5, 2.5]; + let mut mask = vec![0u8; dims[0] * dims[1] * dims[2]]; + let at = |i: usize, j: usize, k: usize| k * dims[0] * dims[1] + j * dims[0] + i; + mask[at(5, 4, 3)] = 1; + mask[at(2, 1, 1)] = 1; + for radii in [ + [[3.0, 3.0], [3.0, 3.0], [3.0, 3.0]], // isotropic + [[4.0, 4.0], [2.0, 2.0], [6.0, 6.0]], // symmetric, anisotropic + [[0.0, 5.0], [3.0, 1.0], [2.0, 8.0]], // one-sided + [[5.0, 0.0], [0.0, 0.0], [0.0, 4.0]], // axes switched off + ] { + let got = dilate_radii(&mask, dims, sp, &radii); + let want = brute_dilate(&mask, dims, sp, &radii); + assert_eq!(got, want, "dilation by {radii:?}"); + } + } + + #[test] + fn a_symmetric_margin_agrees_with_the_plain_ball() { + let dims = [15, 13, 5]; + let sp = [1.0, 1.0, 3.0]; + let mut mask = vec![0u8; dims[0] * dims[1] * dims[2]]; + for k in 1..4 { + for j in 4..9 { + for i in 5..10 { + mask[k * dims[0] * dims[1] + j * dims[0] + i] = 1; + } + } + } + for r in [1.0, 2.5, 4.0] { + assert_eq!( + dilate_radii(&mask, dims, sp, &[[r; 2]; 3]), + dilate_mm(&mask, dims, sp, r), + "dilate by {r}" + ); + assert_eq!( + erode_radii(&mask, dims, sp, &[[r; 2]; 3]), + erode_mm(&mask, dims, sp, r), + "erode by {r}" + ); + } + } + + #[test] + fn eroding_one_side_moves_only_that_surface() { + // A slab, shrunk 4 mm from the low-index side of axis 0 alone. + let dims = [20, 5, 3]; + let sp = [2.0, 2.0, 2.0]; + let at = |i: usize, j: usize, k: usize| k * 100 + j * 20 + i; + let mut mask = vec![0u8; 300]; + for k in 0..3 { + for j in 0..5 { + for i in 4..16 { + mask[at(i, j, k)] = 1; + } + } + } + let out = erode_radii(&mask, dims, sp, &[[4.0, 0.0], [0.0; 2], [0.0; 2]]); + assert_eq!(out[at(5, 2, 1)], 0, "the low side moved in"); + assert_eq!(out[at(6, 2, 1)], 1, "…by 4 mm and no further"); + assert_eq!(out[at(15, 2, 1)], 1, "the high side did not move"); + } + + #[test] + fn a_blur_wider_than_the_volume_still_terminates() { + // A slice thickness of a micron is what a series that declares zero + // gets clamped to; the box width must not follow it to infinity. + let dims = [8, 4, 2]; + let src = vec![3.0f32; dims[0] * dims[1] * dims[2]]; + let out = blur_mm(&src, dims, [1.0, 1.0, 1e-6], 40.0); + assert!(out.iter().all(|v| (v - 3.0).abs() < 1e-3)); + } + #[test] fn a_fully_set_volume_has_no_background_to_measure() { let dims = [4, 4, 4]; diff --git a/src/nn/tensor.rs b/src/nn/tensor.rs index d4fb64d..8e2d1ed 100644 --- a/src/nn/tensor.rs +++ b/src/nn/tensor.rs @@ -167,9 +167,6 @@ impl SendPtr { } } -/// Transposed 3D convolution with kernel = stride = 2: every input voxel -/// projects to a disjoint 2x2x2 output block. `weight`: `[cin, cout, 2, 2, 2]` -/// — PyTorch's `ConvTranspose3d` layout. /// Transposed 3-D convolution with `kernel = stride`, for any stride. /// /// The 2× case has its own hand-tuned routine below; this is the general @@ -267,6 +264,9 @@ pub fn conv_transpose3d_stride( out } +/// Transposed 3D convolution with kernel = stride = 2: every input voxel +/// projects to a disjoint 2x2x2 output block. `weight`: `[cin, cout, 2, 2, 2]` +/// — PyTorch's `ConvTranspose3d` layout. pub fn conv_transpose3d_2x(x: &Act, weight: &[f32], bias: &[f32], cout: usize) -> Act { let (cin, d, h, w) = (x.c, x.d, x.h, x.w); debug_assert_eq!(weight.len(), cin * cout * 8); @@ -395,6 +395,64 @@ mod tests { } } + /// The general `kernel = stride` form against its own definition, at the + /// anisotropic strides the MR body model's decoder actually plans. The + /// 2× fast path is covered above; without this the general path — the + /// only code the MR model runs through — has no test at all. + #[test] + fn transpose_conv_matches_the_definition_at_any_stride() { + for stride in [[1, 2, 2], [2, 2, 1], [1, 1, 2], [3, 2, 1], [1, 1, 1]] { + let [s0, s1, s2] = stride; + let ks = s0 * s1 * s2; + let mut seed = 11u64; + let (cin, cout, d, h, w) = (3usize, 2usize, 3usize, 4usize, 2usize); + let mut x = Act::zeros(cin, d, h, w); + for v in &mut x.data { + *v = rngf(&mut seed); + } + let wt: Vec = (0..cin * cout * ks).map(|_| rngf(&mut seed)).collect(); + let b: Vec = (0..cout).map(|_| rngf(&mut seed)).collect(); + let y = conv_transpose3d_stride(&x, &wt, &b, cout, stride); + assert_eq!( + (y.c, y.d, y.h, y.w), + (cout, d * s0, h * s1, w * s2), + "shape at {stride:?}" + ); + for co in 0..cout { + for z in 0..d { + for yy in 0..h { + for xx in 0..w { + for dz in 0..s0 { + for dy in 0..s1 { + for dx in 0..s2 { + let mut acc = b[co]; + let t = (dz * s1 + dy) * s2 + dx; + for ci in 0..cin { + let xv = x.data[((ci * d + z) * h + yy) * w + xx]; + let wv = wt[(ci * cout + co) * ks + t]; + acc += xv * wv; + } + let got = y.data[((co * d * s0 + z * s0 + dz) * h * s1 + + yy * s1 + + dy) + * w + * s2 + + xx * s2 + + dx]; + assert!( + (got - acc).abs() < 1e-4, + "stride {stride:?} at {co},{z},{yy},{xx}" + ); + } + } + } + } + } + } + } + } + } + #[test] fn tokens_to_volume_transposes() { // 2 tokens x 3 channels -> [3,1,1,2] diff --git a/src/structops.rs b/src/structops.rs new file mode 100644 index 0000000..2f7d6b8 --- /dev/null +++ b/src/structops.rs @@ -0,0 +1,674 @@ +//! Structure algebra: combining contours and segmentations into new ones. +//! +//! Every planning department does this a dozen times a day. `Lungs` is +//! `Lung_L ∪ Lung_R`. `PTV` is `CTV` grown by a margin. `PTV_eval` is that +//! same PTV cropped 5 mm inside the body. A ring for an optimiser objective +//! is one expansion minus a smaller one. None of it is difficult; all of it +//! is tedious and error-prone by hand, and none of it should depend on +//! whether the thing you want to combine happens to be a contour or a +//! painted mask. +//! +//! So this module works in one currency — a binary mask on one lattice — and +//! the caller converts on the way in and out. An RT structure is rasterized +//! onto the grid ([`segmentation::rasterize_roi`]), a segmentation is already +//! there, and the result goes back as either kind. Mixing them is then not a +//! special case but the normal one. +//! +//! A recipe is read left to right: +//! +//! ```text +//! (A ± margin) op (B ± margin) op … → ± margin → cleanup +//! ``` +//! +//! The per-operand margin is what makes the whole thing expressive rather +//! than merely convenient: *crop to* is an intersection whose second operand +//! was shrunk first, and a ring is a subtraction between two expansions of +//! the same structure. + +use anyhow::{bail, Result}; +use rayon::prelude::*; + +use crate::morphology::{self as morph, Radii}; +use crate::volume::Grid; + +/// How two masks are put together. +/// +/// Applied left to right over the operand list, so three operands under +/// [`BoolOp::Subtract`] mean `A − B − C`, which is what everyone expects and +/// is why the order of the list is worth showing in the interface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BoolOp { + /// In any of them. + Union, + /// In all of them. + Intersect, + /// In the first and none of the rest. + Subtract, + /// In an odd number of them. + Xor, +} + +impl BoolOp { + pub const ALL: [BoolOp; 4] = [ + BoolOp::Union, + BoolOp::Intersect, + BoolOp::Subtract, + BoolOp::Xor, + ]; + + pub fn label(&self) -> &'static str { + match self { + BoolOp::Union => "Union (A ∪ B)", + BoolOp::Intersect => "Intersection (A ∩ B)", + BoolOp::Subtract => "Subtraction (A − B)", + BoolOp::Xor => "Symmetric difference (A ⊕ B)", + } + } + + /// How the operand list reads, for the line above the buttons. + pub fn joiner(&self) -> &'static str { + match self { + BoolOp::Union => "∪", + BoolOp::Intersect => "∩", + BoolOp::Subtract => "−", + BoolOp::Xor => "⊕", + } + } + + fn apply(&self, acc: &mut [u8], rhs: &[u8], first: bool) { + if first { + acc.copy_from_slice(rhs); + return; + } + match self { + BoolOp::Union => acc + .par_iter_mut() + .zip(rhs.par_iter()) + .for_each(|(a, &b)| *a |= b), + BoolOp::Intersect => acc + .par_iter_mut() + .zip(rhs.par_iter()) + .for_each(|(a, &b)| *a &= b), + BoolOp::Subtract => acc + .par_iter_mut() + .zip(rhs.par_iter()) + .for_each(|(a, &b)| *a &= 1 - b), + BoolOp::Xor => acc + .par_iter_mut() + .zip(rhs.par_iter()) + .for_each(|(a, &b)| *a ^= b), + } + } +} + +/// A margin in millimetres, in **patient** directions rather than array axes. +/// +/// That distinction is the whole reason this type exists. "8 mm superiorly" +/// has to mean the same thing on an axial CT, a coronal MR and an obliquely +/// acquired series; the direction cosines decide which array axis that is and +/// which way along it. Positive grows, negative shrinks, and the two may be +/// mixed — the expansion runs first, then the contraction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Margin { + pub right: f64, + pub left: f64, + pub anterior: f64, + pub posterior: f64, + pub superior: f64, + pub inferior: f64, +} + +impl Default for Margin { + fn default() -> Self { + Margin::NONE + } +} + +impl Margin { + pub const NONE: Margin = Margin::uniform(0.0); + + pub const fn uniform(mm: f64) -> Margin { + Margin { + right: mm, + left: mm, + anterior: mm, + posterior: mm, + superior: mm, + inferior: mm, + } + } + + pub fn is_none(&self) -> bool { + self.all().iter().all(|v| v.abs() < 1e-9) + } + + /// True when one number describes it, which is what the interface offers + /// until the user asks for more. + pub fn is_uniform(&self) -> bool { + let v = self.all(); + v.iter().all(|x| (x - v[0]).abs() < 1e-9) + } + + /// Right, left, anterior, posterior, superior, inferior. + pub fn all(&self) -> [f64; 6] { + [ + self.right, + self.left, + self.anterior, + self.posterior, + self.superior, + self.inferior, + ] + } + + /// `+5.0 mm`, or the six values when they differ. + pub fn describe(&self) -> String { + if self.is_none() { + "none".to_string() + } else if self.is_uniform() { + format!("{:+.1} mm", self.right) + } else { + format!( + "R{:+.0} L{:+.0} A{:+.0} P{:+.0} S{:+.0} I{:+.0}", + self.right, self.left, self.anterior, self.posterior, self.superior, self.inferior + ) + } + } + + /// Split into the part that grows and the part that shrinks, each mapped + /// onto the lattice's own axes and directions. + /// + /// Canonical axis 0 is superior, 1 anterior, 2 right (see + /// [`Grid::canonical_axes`]); `flip` says that the canonical direction + /// runs *against* the array index, which swaps the pair. + fn to_radii(self, grid: &Grid) -> (Radii, Radii) { + let (perm, flip) = grid.canonical_axes(); + // Per canonical axis: (toward +canonical, toward −canonical). + let pairs = [ + (self.superior, self.inferior), + (self.anterior, self.posterior), + (self.right, self.left), + ]; + let mut grow: Radii = [[0.0; 2]; 3]; + let mut shrink: Radii = [[0.0; 2]; 3]; + for (c, (plus, minus)) in pairs.into_iter().enumerate() { + let axis = perm[c]; + // radii[axis] = [toward decreasing index, toward increasing index] + let (lo, hi) = if flip[c] { + (plus, minus) + } else { + (minus, plus) + }; + grow[axis] = [lo.max(0.0), hi.max(0.0)]; + shrink[axis] = [(-lo).max(0.0), (-hi).max(0.0)]; + } + (grow, shrink) + } + + /// Grow, then shrink. Both are no-ops when their half of the margin is + /// zero, so the common uniform case costs one distance transform. + pub fn apply(&self, mask: &[u8], grid: &Grid, sink: &dyn ProgressSink) -> Vec { + if self.is_none() { + return mask.to_vec(); + } + let (grow, shrink) = self.to_radii(grid); + let mut out = mask.to_vec(); + if morph_any(&grow) { + sink.report(0.0, "Expanding…"); + out = morph::dilate_radii(&out, grid.dims, grid.spacing, &grow); + } + if morph_any(&shrink) { + sink.report(0.5, "Contracting…"); + out = morph::erode_radii(&out, grid.dims, grid.spacing, &shrink); + } + out + } +} + +fn morph_any(r: &Radii) -> bool { + r.iter().flatten().any(|v| *v > 0.0) +} + +/// Tidying applied to the finished mask. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Cleanup { + /// Close interior cavities, slice by slice — the same reasoning as the + /// body contour: a lung drains through the trachea, so a three- + /// dimensional fill would leave it open. + pub fill_holes: bool, + /// Morphological closing, to take the staircase off a surface. + pub close_mm: f64, + /// Discard everything but the largest connected piece. + pub keep_largest: bool, + /// …or, less bluntly, discard pieces below this volume. Ignored when + /// `keep_largest` is set. + pub min_volume_cm3: f64, +} + +impl Default for Cleanup { + fn default() -> Self { + Cleanup { + fill_holes: false, + close_mm: 0.0, + keep_largest: false, + min_volume_cm3: 0.0, + } + } +} + +impl Cleanup { + pub fn is_none(&self) -> bool { + !self.fill_holes && self.close_mm <= 0.0 && !self.keep_largest && self.min_volume_cm3 <= 0.0 + } + + pub fn apply(&self, mask: &mut Vec, grid: &Grid, sink: &dyn ProgressSink) { + if self.close_mm > 0.0 { + sink.report(0.0, "Smoothing…"); + *mask = morph::close_mm(mask, grid.dims, grid.spacing, self.close_mm); + } + if self.fill_holes { + sink.report(0.4, "Filling cavities…"); + morph::fill_holes_2d(mask, grid.dims, grid.canonical_axes().0[0]); + } + if self.keep_largest || self.min_volume_cm3 > 0.0 { + sink.report(0.7, "Dropping small pieces…"); + let voxel_cm3 = grid.spacing[0] * grid.spacing[1] * grid.spacing[2] / 1000.0; + let comps = morph::components(mask, grid.dims); + let keep: Vec<&morph::Component> = if self.keep_largest { + comps.iter().take(1).collect() + } else { + let min = (self.min_volume_cm3 / voxel_cm3).max(1.0) as usize; + comps.iter().filter(|c| c.len() >= min).collect() + }; + mask.fill(0); + for c in keep { + for &v in &c.voxels { + mask[v as usize] = 1; + } + } + } + } +} + +/// One input to a recipe: a mask on the recipe's lattice, and the margin +/// applied to it before it is combined with the others. +pub struct Operand { + /// Shown in messages and in the summary line. + pub name: String, + pub mask: Vec, + pub margin: Margin, +} + +/// What to compute. +pub struct Recipe { + pub op: BoolOp, + pub operands: Vec, + /// Applied to the combined result. + pub margin: Margin, + pub cleanup: Cleanup, +} + +pub use crate::progress::ProgressSink; + +/// What a finished recipe hands back. The mask is named rather than printed +/// in the `Debug` output, which is otherwise tens of megabytes of ones. +pub struct Combined { + pub mask: Vec, + pub voxels: u64, + pub cm3: f64, + /// Separate pieces in the result — worth saying, because a subtraction + /// that cuts a structure in two is rarely what was intended. + pub pieces: usize, +} + +/// Evaluate a recipe on `grid`. +/// +/// Every operand must already be a mask on that lattice; converting a +/// contour is the caller's job, since only it knows where the contour came +/// from. +impl std::fmt::Debug for Combined { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Combined") + .field("mask", &format_args!("<{} voxels>", self.mask.len())) + .field("voxels", &self.voxels) + .field("cm3", &self.cm3) + .field("pieces", &self.pieces) + .finish() + } +} + +pub fn combine(recipe: &Recipe, grid: &Grid, sink: &dyn ProgressSink) -> Result { + let n = grid.dims[0] * grid.dims[1] * grid.dims[2]; + if recipe.operands.is_empty() { + bail!("nothing to combine — add at least one structure"); + } + if recipe.op == BoolOp::Subtract && recipe.operands.len() < 2 { + bail!("a subtraction needs something to subtract"); + } + for o in &recipe.operands { + if o.mask.len() != n { + bail!( + "'{}' is on a different lattice ({} voxels, expected {n})", + o.name, + o.mask.len() + ); + } + } + let steps = recipe.operands.len() as f32 + 2.0; + let mut acc = vec![0u8; n]; + for (i, operand) in recipe.operands.iter().enumerate() { + sink.report(i as f32 / steps, &format!("Preparing '{}'…", operand.name)); + if sink.cancelled() { + bail!(crate::progress::CANCELLED); + } + // Normalize to 0/1 first: a mask that arrived as a label map would + // otherwise make `^` and `&` mean something else entirely. + let mut m: Vec = operand.mask.par_iter().map(|&v| u8::from(v != 0)).collect(); + if !operand.margin.is_none() { + m = operand.margin.apply(&m, grid, &crate::progress::Quiet); + } + recipe.op.apply(&mut acc, &m, i == 0); + } + if sink.cancelled() { + bail!(crate::progress::CANCELLED); + } + if !recipe.margin.is_none() { + sink.report(recipe.operands.len() as f32 / steps, "Applying the margin…"); + acc = recipe.margin.apply(&acc, grid, &crate::progress::Quiet); + } + if !recipe.cleanup.is_none() { + sink.report((recipe.operands.len() as f32 + 1.0) / steps, "Cleaning up…"); + recipe + .cleanup + .apply(&mut acc, grid, &crate::progress::Quiet); + } + let voxels: u64 = acc.par_iter().map(|&v| u64::from(v != 0)).sum(); + let pieces = if voxels == 0 { + 0 + } else { + morph::components(&acc, grid.dims).len() + }; + let voxel_cm3 = grid.spacing[0] * grid.spacing[1] * grid.spacing[2] / 1000.0; + sink.report(1.0, "Done"); + Ok(Combined { + mask: acc, + voxels, + cm3: voxels as f64 * voxel_cm3, + pieces, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::Vec3; + use crate::progress::Quiet; + + 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(), + } + } + + fn operand(name: &str, mask: Vec) -> Operand { + Operand { + name: name.into(), + mask, + margin: Margin::NONE, + } + } + + fn run(op: BoolOp, masks: Vec>, g: &Grid) -> Vec { + let recipe = Recipe { + op, + operands: masks + .into_iter() + .enumerate() + .map(|(i, m)| operand(&format!("s{i}"), m)) + .collect(), + margin: Margin::NONE, + cleanup: Cleanup::default(), + }; + combine(&recipe, g, &Quiet).expect("a result").mask + } + + #[test] + fn the_four_operations_are_the_four_operations() { + let g = grid([4, 1, 1], [1.0; 3]); + let a = vec![1, 1, 0, 0]; + let b = vec![0, 1, 1, 0]; + assert_eq!( + run(BoolOp::Union, vec![a.clone(), b.clone()], &g), + [1, 1, 1, 0] + ); + assert_eq!( + run(BoolOp::Intersect, vec![a.clone(), b.clone()], &g), + [0, 1, 0, 0] + ); + assert_eq!( + run(BoolOp::Subtract, vec![a.clone(), b.clone()], &g), + [1, 0, 0, 0] + ); + assert_eq!(run(BoolOp::Xor, vec![a, b], &g), [1, 0, 1, 0]); + } + + #[test] + fn more_than_two_operands_fold_left_to_right() { + let g = grid([5, 1, 1], [1.0; 3]); + let a = vec![1, 1, 1, 1, 1]; + let b = vec![0, 1, 0, 0, 0]; + let c = vec![0, 0, 0, 1, 0]; + // A − B − C, not A − (B − C). + assert_eq!(run(BoolOp::Subtract, vec![a, b, c], &g), [1, 0, 1, 0, 1]); + } + + #[test] + fn a_label_map_operand_is_read_as_a_mask_not_as_numbers() { + // Values other than 0/1 must not survive into `^` or `&`. + let g = grid([3, 1, 1], [1.0; 3]); + let a = vec![7, 0, 3]; + let b = vec![9, 9, 0]; + assert_eq!(run(BoolOp::Xor, vec![a, b], &g), [0, 1, 1]); + } + + #[test] + fn a_margin_is_measured_in_patient_directions_not_array_axes() { + // Two lattices of the same anatomy, the second with its rows running + // the other way. A 4 mm superior margin has to land on the same side + // of the patient in both. + let dims = [3, 3, 9]; + let mut mask = vec![0u8; 81]; + let at = |i: usize, j: usize, k: usize| k * 9 + j * 3 + i; + mask[at(1, 1, 4)] = 1; + let mut up = grid(dims, [2.0, 2.0, 2.0]); + up.normal = Vec3::new(0.0, 0.0, 1.0); // +k is superior + let mut down = up.clone(); + down.normal = Vec3::new(0.0, 0.0, -1.0); // +k is inferior + + let m = Margin { + superior: 4.0, + ..Margin::NONE + }; + let a = m.apply(&mask, &up, &Quiet); + let b = m.apply(&mask, &down, &Quiet); + assert_eq!(a[at(1, 1, 6)], 1, "grew toward +k when +k is superior"); + assert_eq!(a[at(1, 1, 2)], 0, "and not the other way"); + assert_eq!(b[at(1, 1, 2)], 1, "grew toward −k when −k is superior"); + assert_eq!(b[at(1, 1, 6)], 0, "and not the other way"); + } + + #[test] + fn a_negative_margin_shrinks_and_a_mixed_one_does_both() { + let dims = [21, 21, 1]; + let g = grid(dims, [1.0, 1.0, 1.0]); + let at = |i: usize, j: usize| j * 21 + i; + let mut mask = vec![0u8; 441]; + for j in 5..16 { + for i in 5..16 { + mask[at(i, j)] = 1; + } + } + let shrunk = Margin::uniform(-2.0).apply(&mask, &g, &Quiet); + assert_eq!(shrunk[at(10, 10)], 1, "the middle survives"); + assert_eq!(shrunk[at(6, 10)], 0, "the edge moved in"); + // Grow right, shrink left: the two halves move independently. + let mixed = Margin { + right: 3.0, + left: -3.0, + ..Margin::NONE + } + .apply(&mask, &g, &Quiet); + // +x is Left in LPS, so "right" grows toward decreasing i. + assert_eq!(mixed[at(3, 10)], 1, "grew to the patient's right"); + assert_eq!(mixed[at(15, 10)], 0, "shrank on the patient's left"); + } + + #[test] + fn cleanup_fills_closes_and_prunes() { + let dims = [30, 30, 1]; + let g = grid(dims, [1.0, 1.0, 1.0]); + let at = |i: usize, j: usize| j * 30 + i; + let mut mask = vec![0u8; 900]; + // A ring, plus a speck far away. + for j in 3..18 { + for i in 3..18 { + mask[at(i, j)] = 1; + } + } + for j in 8..13 { + for i in 8..13 { + mask[at(i, j)] = 0; + } + } + mask[at(27, 27)] = 1; + let mut filled = mask.clone(); + Cleanup { + fill_holes: true, + ..Cleanup::default() + } + .apply(&mut filled, &g, &Quiet); + assert_eq!(filled[at(10, 10)], 1, "the hole closed"); + assert_eq!(filled[at(27, 27)], 1, "the speck is still there"); + + let mut pruned = mask.clone(); + Cleanup { + keep_largest: true, + ..Cleanup::default() + } + .apply(&mut pruned, &g, &Quiet); + assert_eq!(pruned[at(4, 4)], 1, "the ring survived"); + assert_eq!(pruned[at(27, 27)], 0, "the speck did not"); + } + + #[test] + fn a_crop_is_an_intersection_with_a_shrunken_operand() { + // PTV_eval = PTV ∩ (BODY − 5 mm), the everyday use of a per-operand + // margin. + let dims = [41, 5, 1]; + let g = grid(dims, [1.0, 1.0, 1.0]); + let at = |i: usize| 2 * 41 + i; + let mut body = vec![0u8; 205]; + let mut ptv = vec![0u8; 205]; + for j in 0..5 { + for i in 5..36 { + body[j * 41 + i] = 1; + } + for i in 3..20 { + ptv[j * 41 + i] = 1; + } + } + let out = combine( + &Recipe { + op: BoolOp::Intersect, + operands: vec![ + operand("PTV", ptv), + Operand { + name: "BODY".into(), + mask: body, + margin: Margin::uniform(-5.0), + }, + ], + margin: Margin::NONE, + cleanup: Cleanup::default(), + }, + &g, + &Quiet, + ) + .expect("a result"); + assert_eq!(out.mask[at(9)], 0, "outside the shrunken body"); + assert_eq!(out.mask[at(11)], 1, "inside both"); + assert_eq!(out.mask[at(25)], 0, "outside the PTV"); + assert_eq!(out.pieces, 1); + } + + #[test] + fn a_ring_is_the_difference_of_two_expansions() { + let dims = [41, 41, 1]; + let g = grid(dims, [1.0, 1.0, 1.0]); + let at = |i: usize, j: usize| j * 41 + i; + let mut core = vec![0u8; 1681]; + for j in 18..23 { + for i in 18..23 { + core[at(i, j)] = 1; + } + } + let out = combine( + &Recipe { + op: BoolOp::Subtract, + operands: vec![ + Operand { + name: "outer".into(), + mask: core.clone(), + margin: Margin::uniform(10.0), + }, + Operand { + name: "inner".into(), + mask: core, + margin: Margin::uniform(4.0), + }, + ], + margin: Margin::NONE, + cleanup: Cleanup::default(), + }, + &g, + &Quiet, + ) + .expect("a result"); + assert_eq!(out.mask[at(20, 20)], 0, "hollow in the middle"); + assert_eq!(out.mask[at(20, 12)], 1, "solid in the ring"); + assert_eq!(out.mask[at(20, 4)], 0, "and nothing beyond it"); + } + + #[test] + fn an_empty_or_mismatched_recipe_is_refused_not_guessed() { + let g = grid([4, 4, 1], [1.0; 3]); + let empty = Recipe { + op: BoolOp::Union, + operands: Vec::new(), + margin: Margin::NONE, + cleanup: Cleanup::default(), + }; + assert!(combine(&empty, &g, &Quiet).is_err()); + let lone = Recipe { + op: BoolOp::Subtract, + operands: vec![operand("A", vec![0u8; 16])], + margin: Margin::NONE, + cleanup: Cleanup::default(), + }; + assert!(combine(&lone, &g, &Quiet).is_err()); + let wrong = Recipe { + op: BoolOp::Union, + operands: vec![operand("A", vec![0u8; 9])], + margin: Margin::NONE, + cleanup: Cleanup::default(), + }; + let err = format!("{:#}", combine(&wrong, &g, &Quiet).unwrap_err()); + assert!(err.contains("lattice"), "{err}"); + } +} diff --git a/src/volume.rs b/src/volume.rs index 3c9182b..35ac86d 100644 --- a/src/volume.rs +++ b/src/volume.rs @@ -58,6 +58,60 @@ pub struct Grid { } impl Grid { + /// Find the permutation and flips that carry a lattice's own axes onto the + /// canonical `[S, A, R]` order — superior, anterior and right, each + /// increasing with the array index. + /// + /// Every inference engine wants this: it is what `nibabel`'s + /// `as_closest_canonical` followed by nnU-Net's axis convention produces, and + /// it is also what SegVol's `Orientationd(axcodes="RAS")` plus its + /// `DimTranspose` (which swaps the first and last spatial axes) produces. + /// Volumes are not assumed to be axis-aligned; the best match is chosen by + /// direction cosine. + pub fn canonical_axes(&self) -> ([usize; 3], [bool; 3]) { + // LPS direction vectors of the three volume axes. + let dirs: [Vec3; 3] = [self.row_dir, self.col_dir, self.normal]; + // Canonical targets in LPS: S = +z, A = -y, R = -x. + let targets: [Vec3; 3] = [ + Vec3 { + x: 0.0, + y: 0.0, + z: 1.0, + }, + Vec3 { + x: 0.0, + y: -1.0, + z: 0.0, + }, + Vec3 { + x: -1.0, + y: 0.0, + z: 0.0, + }, + ]; + let mut perm = [0usize; 3]; + let mut flip = [false; 3]; + let mut used = [false; 3]; + for a in 0..3 { + let mut best = 0usize; + let mut best_dot = f64::NEG_INFINITY; + for v in 0..3 { + if used[v] { + continue; + } + let dot = dirs[v].dot(targets[a]); + if dot.abs() > best_dot { + best_dot = dot.abs(); + best = v; + } + } + used[best] = true; + perm[a] = best; + flip[a] = dirs[best].dot(targets[a]) < 0.0; + } + (perm, flip) + } + pub fn voxel_count(&self) -> usize { self.dims[0] * self.dims[1] * self.dims[2] } @@ -321,56 +375,8 @@ impl Volume { } /// Find the permutation and flips that carry a volume's own axes onto the - /// canonical `[S, A, R]` order — superior, anterior and right, each - /// increasing with the array index. - /// - /// Every inference engine wants this: it is what `nibabel`'s - /// `as_closest_canonical` followed by nnU-Net's axis convention produces, and - /// it is also what SegVol's `Orientationd(axcodes="RAS")` plus its - /// `DimTranspose` (which swaps the first and last spatial axes) produces. - /// Volumes are not assumed to be axis-aligned; the best match is chosen by - /// direction cosine. + /// canonical `[S, A, R]` order — see [`Grid::canonical_axes`]. pub fn canonical_axes(&self) -> ([usize; 3], [bool; 3]) { - // LPS direction vectors of the three volume axes. - let dirs: [Vec3; 3] = [self.row_dir, self.col_dir, self.normal]; - // Canonical targets in LPS: S = +z, A = -y, R = -x. - let targets: [Vec3; 3] = [ - Vec3 { - x: 0.0, - y: 0.0, - z: 1.0, - }, - Vec3 { - x: 0.0, - y: -1.0, - z: 0.0, - }, - Vec3 { - x: -1.0, - y: 0.0, - z: 0.0, - }, - ]; - let mut perm = [0usize; 3]; - let mut flip = [false; 3]; - let mut used = [false; 3]; - for a in 0..3 { - let mut best = 0usize; - let mut best_dot = f64::NEG_INFINITY; - for v in 0..3 { - if used[v] { - continue; - } - let dot = dirs[v].dot(targets[a]); - if dot.abs() > best_dot { - best_dot = dot.abs(); - best = v; - } - } - used[best] = true; - perm[a] = best; - flip[a] = dirs[best].dot(targets[a]) < 0.0; - } - (perm, flip) + self.grid().canonical_axes() } } diff --git a/tests/body.rs b/tests/body.rs index b866054..e94c74b 100644 --- a/tests/body.rs +++ b/tests/body.rs @@ -227,10 +227,9 @@ fn a_shell_pressed_against_the_skin_is_kept_and_the_cost_is_bounded() { let p = phantom(); let r = contour_body(&p.volume, ¶ms(), nowhere(), &Progress::default()).expect("a body"); let kept = CONTACT - .filter_map(shell_row) - .flat_map(|j| (0..NZ).map(move |k| (j, k))) - .zip(CONTACT.cycle()) - .filter(|((j, k), i)| r.mask[idx(*i, *j, *k)] != 0) + .filter_map(|i| shell_row(i).map(|j| (i, j))) + .flat_map(|(i, j)| (0..NZ).map(move |k| (i, j, k))) + .filter(|&(i, j, k)| r.mask[idx(i, j, k)] != 0) .count(); assert!(kept > 0, "the contact patch is expected to survive"); let extra = r diff --git a/tests/structops.rs b/tests/structops.rs new file mode 100644 index 0000000..88ee972 --- /dev/null +++ b/tests/structops.rs @@ -0,0 +1,290 @@ +//! Structure algebra against contours and segments as they actually arrive: +//! an RT structure rasterized from patient-space polygons, a segmentation +//! painted on the voxel grid, and the two combined without either knowing +//! about the other. +//! +//! The unit tests in `src/structops.rs` cover the algebra on bare masks. +//! What is worth testing here is the seam: that a contour and a mask +//! describing the same sphere really do meet, that a margin given in patient +//! directions lands where the direction cosines say it should even when the +//! series is stored the other way up, and that the result survives the round +//! trip back out to contours. + +use rust_dicom_station::geometry::Vec3; +use rust_dicom_station::progress::Quiet; +use rust_dicom_station::rtstruct::{Contour, Roi}; +use rust_dicom_station::segmentation::{self, Segmentation}; +use rust_dicom_station::structops::{combine, BoolOp, Cleanup, Margin, Operand, Recipe}; +use rust_dicom_station::volume::Grid; + +const DIMS: [usize; 3] = [40, 40, 12]; +const SP: [f64; 3] = [2.0, 2.0, 5.0]; + +fn idx(i: usize, j: usize, k: usize) -> usize { + k * DIMS[0] * DIMS[1] + j * DIMS[0] + i +} + +/// A lattice whose k axis runs superiorly (`up`) or inferiorly. +fn grid(up: bool) -> Grid { + Grid { + dims: DIMS, + spacing: SP, + 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, if up { 1.0 } else { -1.0 }), + frame_of_reference_uid: "1.2.826.0.1.3680043.8.498.algebra".into(), + } +} + +/// A square contour on every slice, in patient coordinates — an RT structure +/// as a file would carry it. +fn boxed_roi(name: &str, lo: [f64; 2], hi: [f64; 2], g: &Grid) -> Roi { + let contours = (0..DIMS[2]) + .map(|k| { + let z = g.voxel_to_patient(0.0, 0.0, k as f64).z; + Contour { + geometric_type: "CLOSED_PLANAR".into(), + points: vec![ + Vec3::new(lo[0], lo[1], z), + Vec3::new(hi[0], lo[1], z), + Vec3::new(hi[0], hi[1], z), + Vec3::new(lo[0], hi[1], z), + ], + } + }) + .collect(); + Roi { + number: 1, + name: name.into(), + color: [255, 0, 0], + roi_type: "ORGAN".into(), + contours, + } +} + +fn painted(name: &str, lo: [usize; 2], hi: [usize; 2]) -> Segmentation { + let mut mask = vec![0u8; DIMS[0] * DIMS[1] * DIMS[2]]; + for k in 0..DIMS[2] { + for j in lo[1]..hi[1] { + for i in lo[0]..hi[0] { + mask[idx(i, j, k)] = 1; + } + } + } + Segmentation::from_mask(name.into(), [0, 255, 0], DIMS, mask) +} + +fn operand(name: &str, mask: Vec, margin: Margin) -> Operand { + Operand { + name: name.into(), + mask, + margin, + } +} + +fn recipe(op: BoolOp, operands: Vec) -> Recipe { + Recipe { + op, + operands, + margin: Margin::NONE, + cleanup: Cleanup::default(), + } +} + +#[test] +fn a_contour_and_a_painted_mask_combine_as_one_kind() { + let g = grid(true); + // The contour covers i 5..25; the painted mask i 15..35. Both span the + // same rows and every slice, so the overlap is i 15..25. + let roi = boxed_roi("contoured", [10.0, 20.0], [50.0, 60.0], &g); + let contour_mask = segmentation::rasterize_roi(&g, &roi).expect("the contour rasterizes"); + let seg = painted("painted", [15, 10], [35, 30]); + + let both = combine( + &recipe( + BoolOp::Intersect, + vec![ + operand("contoured", contour_mask.clone(), Margin::NONE), + operand("painted", seg.mask.clone(), Margin::NONE), + ], + ), + &g, + &Quiet, + ) + .expect("a result"); + assert!(both.voxels > 0, "the two overlap"); + assert_eq!(both.mask[idx(20, 15, 5)], 1, "inside both"); + assert_eq!(both.mask[idx(8, 15, 5)], 0, "contour only"); + assert_eq!(both.mask[idx(32, 15, 5)], 0, "mask only"); + + let either = combine( + &recipe( + BoolOp::Union, + vec![ + operand("contoured", contour_mask, Margin::NONE), + operand("painted", seg.mask, Margin::NONE), + ], + ), + &g, + &Quiet, + ) + .expect("a result"); + assert_eq!(either.mask[idx(8, 15, 5)], 1); + assert_eq!(either.mask[idx(32, 15, 5)], 1); + assert!(either.voxels > both.voxels); +} + +#[test] +fn a_result_converts_back_to_contours_that_enclose_the_same_voxels() { + let g = grid(true); + let a = painted("a", [8, 8], [24, 24]).mask; + let b = painted("b", [16, 16], [32, 32]).mask; + let out = combine( + &recipe( + BoolOp::Union, + vec![operand("a", a, Margin::NONE), operand("b", b, Margin::NONE)], + ), + &g, + &Quiet, + ) + .expect("a result"); + + let seg = Segmentation::from_mask("union".into(), [255, 255, 0], DIMS, out.mask.clone()); + let roi = segmentation::mask_to_roi(&seg, &g, 7); + assert!(!roi.contours.is_empty(), "the union has an outline"); + let back = segmentation::rasterize_roi(&g, &roi).expect("it rasterizes again"); + // The L-shaped union is not convex, so this is a real test of the + // contour walk rather than of a bounding box. + let differing = back + .iter() + .zip(out.mask.iter()) + .filter(|(x, y)| (**x != 0) != (**y != 0)) + .count(); + let total = out.voxels as usize; + assert!( + differing * 200 < total, + "{differing} of {total} voxels changed across the round trip" + ); +} + +#[test] +fn a_superior_margin_follows_the_patient_not_the_array() { + // The same anatomy stored two ways up. A superior margin has to grow + // toward the head in both, which is opposite array directions. + let mut mask = vec![0u8; DIMS[0] * DIMS[1] * DIMS[2]]; + for j in 18..22 { + for i in 18..22 { + mask[idx(i, j, 6)] = 1; + } + } + let m = Margin { + superior: 10.0, + ..Margin::NONE + }; + let up = m.apply(&mask, &grid(true), &Quiet); + let down = m.apply(&mask, &grid(false), &Quiet); + assert_eq!(up[idx(20, 20, 8)], 1, "toward +k when +k is superior"); + assert_eq!(up[idx(20, 20, 4)], 0); + assert_eq!(down[idx(20, 20, 4)], 1, "toward −k when −k is superior"); + assert_eq!(down[idx(20, 20, 8)], 0); +} + +#[test] +fn subtracting_the_wrong_way_round_gives_nothing_and_says_so() { + // The mistake the tool exists to make easy to spot: a small structure + // minus the big one it sits inside is empty, and the caller has to be + // able to tell that apart from an error. + let g = grid(true); + let small = painted("small", [18, 18], [22, 22]).mask; + let big = painted("big", [5, 5], [35, 35]).mask; + let wrong = combine( + &recipe( + BoolOp::Subtract, + vec![ + operand("small", small.clone(), Margin::NONE), + operand("big", big.clone(), Margin::NONE), + ], + ), + &g, + &Quiet, + ) + .expect("a result, just an empty one"); + assert_eq!(wrong.voxels, 0); + assert_eq!(wrong.pieces, 0); + + let right = combine( + &recipe( + BoolOp::Subtract, + vec![ + operand("big", big, Margin::NONE), + operand("small", small, Margin::NONE), + ], + ), + &g, + &Quiet, + ) + .expect("a result"); + assert!(right.voxels > 0); + assert_eq!( + right.mask[idx(20, 20, 5)], + 0, + "the hole is where it belongs" + ); + assert_eq!(right.mask[idx(10, 10, 5)], 1); +} + +#[test] +fn cleanup_can_rescue_a_subtraction_that_left_slivers() { + let g = grid(true); + let mut body = vec![0u8; DIMS[0] * DIMS[1] * DIMS[2]]; + for k in 0..DIMS[2] { + for j in 8..32 { + for i in 8..32 { + body[idx(i, j, k)] = 1; + } + } + } + // A cut straight across, leaving two pieces of very different size. + let mut knife = vec![0u8; DIMS[0] * DIMS[1] * DIMS[2]]; + for k in 0..DIMS[2] { + for j in 8..32 { + for i in 10..32 { + knife[idx(i, j, k)] = 1; + } + } + } + let split = combine( + &recipe( + BoolOp::Subtract, + vec![ + operand("body", body.clone(), Margin::NONE), + operand("knife", knife.clone(), Margin::NONE), + ], + ), + &g, + &Quiet, + ) + .expect("a result"); + assert_eq!(split.pieces, 1, "one sliver survives the cut"); + + let kept = combine( + &Recipe { + op: BoolOp::Subtract, + operands: vec![ + operand("body", body, Margin::NONE), + operand("knife", knife, Margin::NONE), + ], + margin: Margin::NONE, + cleanup: Cleanup { + keep_largest: true, + ..Cleanup::default() + }, + }, + &g, + &Quiet, + ) + .expect("a result"); + assert_eq!(kept.pieces, 1); + assert!(kept.voxels <= split.voxels); +} From 19b751859d7e8f5a2bfcf72858af3440c7b57875 Mon Sep 17 00:00:00 2001 From: alexprotom Date: Mon, 31 Aug 2026 14:44:32 +0200 Subject: [PATCH 3/5] Added new comparison tool, GUI updates --- Cargo.lock | 2 +- docs/README.md | 1 + docs/architecture.md | 20 +- docs/motion-4d.md | 159 ++++++ docs/viewer.md | 14 + src/app/body_win.rs | 17 +- src/app/box_seg.rs | 17 +- src/app/chrome.rs | 48 +- src/app/combine_win.rs | 17 +- src/app/compare_win.rs | 237 +++++++++ src/app/d3.rs | 16 +- src/app/detach.rs | 293 +++++++++++ src/app/dialogs.rs | 94 ++-- src/app/drr_win.rs | 17 +- src/app/mod.rs | 106 ++++ src/app/models_win.rs | 17 +- src/app/motion_results.rs | 523 +++++++++++++++++++ src/app/motion_win.rs | 1021 +++++++++++++++++++++++++++++++++++++ src/app/pacs_win.rs | 16 +- src/app/panels.rs | 348 +++++++++++++ src/app/planar.rs | 16 +- src/app/prompt_seg.rs | 17 +- src/app/propagate_win.rs | 321 ++++++------ src/app/rename.rs | 23 +- src/app/seg_engines.rs | 19 +- src/app/transfer_win.rs | 378 ++++++++++++++ src/app/tree.rs | 9 + src/fourd.rs | 643 +++++++++++++++++++++++ src/lib.rs | 2 + src/loader.rs | 40 ++ src/motion.rs | 787 ++++++++++++++++++++++++++++ src/settings.rs | 43 ++ src/simulate.rs | 3 + 33 files changed, 5014 insertions(+), 270 deletions(-) create mode 100644 docs/motion-4d.md create mode 100644 src/app/compare_win.rs create mode 100644 src/app/detach.rs create mode 100644 src/app/motion_results.rs create mode 100644 src/app/motion_win.rs create mode 100644 src/app/transfer_win.rs create mode 100644 src/fourd.rs create mode 100644 src/motion.rs 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/docs/README.md b/docs/README.md index f1d724a..aa5031c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ covers one area in depth. | [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 | +| [motion-4d.md](motion-4d.md) | 4D sub-studies and the motion workflow: phase grouping (automatic + by hand), the per-phase registration/propagation pipeline, centroid and drift metrics with correlations, ITV generation, the results window with A/B comparison and CSV export, transfer by relationship, structure comparison (Dice, HD95) | | [structure-algebra.md](structure-algebra.md) | Combining structures and segmentations: union / intersection / subtraction / symmetric difference, margins in patient directions, cropping, cleanup, and how contours and masks are made interchangeable | | [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 | diff --git a/docs/architecture.md b/docs/architecture.md index 600e2da..97cdc92 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -203,6 +203,8 @@ src/ plumbing (Job::spawn, poll_job, poll_tool_job), per-frame driver theme.rs theme-dependent colors chrome.rs menu bar, toolbar, status bar, help + detach.rs every tool window, docked in the main window or in its + own window of the operating system (a second monitor) panels.rs left panel: its show / hide, the optional modules and the per-dataset Data tree sections views.rs central MPR viewports, interaction, texture caches @@ -233,9 +235,19 @@ src/ combine_win.rs the structure-algebra window: the ordered operand list, per-operand and per-direction margins, the recipe line, and landing the answer as a segment or a contour - seg_engines.rs what the four tool windows share: names and glyphs, + seg_engines.rs what the six tool windows share: names and glyphs, device / model-folder / licence / progress rows, result landing, the "still the same dataset" check + motion_win.rs the 4D motion / ITV window: group, reference phase, + targets, models, ITV options, and the per-phase pipeline + worker (register ▸ propagate ▸ measure ▸ ITV) + motion_results.rs the motion results window: hand-drawn displacement / + amplitude charts, per-phase tables, correlations, QA, + CSV export, and the run-vs-run (A/B) comparison + transfer_win.rs transfer by relationship: a structure lands in the other + dataset at its offset from a reference structure + compare_win.rs compare structures: volumes, centroid offset, Dice, + HD95, mean surface distance 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) @@ -272,6 +284,12 @@ 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 + fourd.rs 4D sub-studies: phase recognition from descriptions and + temporal identifiers, ordered groups (phases + AVG / MIP), + custom-group refresh rules Core + 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 Reg drr.rs digitally reconstructed radiographs: IEC cone-beam geometry, Siddon exact tracing and ITK-style interpolating ray-casting Sim segmentation.rs voxel masks: brush, geodesic grow, undo, overlays, diff --git a/docs/motion-4d.md b/docs/motion-4d.md new file mode 100644 index 0000000..a3bca6d --- /dev/null +++ b/docs/motion-4d.md @@ -0,0 +1,159 @@ +# 4D motion analysis and ITV generation + +This document covers the 4D workflow: how image series are grouped into 4D +sub-studies, what the **📈 4D motion / ITV** tool computes and how, where the +results land, and the two companion tools — **◎ Transfer by relationship** +and **◑ Compare structures**. The 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, +with the same run repeatable on another dataset for a posture or cohort +comparison. + +## 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 implies +the acquisition they form but stores no node for it, 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 the keyword form `phase` + number (`4DCT_phase_000`, `Phase 3`) + is a **phase**; the description with the number removed (the + *template*) tells two 4D sets in one study apart, so a thin- and a + thick-slice reconstruction of the same phases become two groups. The + template is also 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 by hand from the +data tree: right-click a series ▸ *4D group* to add it to a group or start +a new one; right-click a group member to reorder it, change its role or +remove it; right-click the group node to rename it, dissolve it or re-run +detection. Hand-edited groups are marked *custom* and survive re-detection +(`fourd::refresh`, which runs whenever the series list changes). A +dissolved auto-detected group leaves a hidden tombstone so the next refresh +does not rebuild it; only the explicit *Re-detect 4D groups* clears +tombstones. Members reference series by **SeriesInstanceUID**, so renames, +removals and copies never corrupt a group — a member whose series is gone +simply drops out of view. + +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 in the tree. Clicking a member +displays that series, exactly like an ordinary series row. + +## 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). + The targets are defined on it: contours are rasterized onto its + lattice, segmentations drawn on another lattice are 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, + so the deformable transform is rigid + correction. The settings + (levels, iterations, samples, grid spacing, sampling threshold) start + from the Registration panel's current values and can be adjusted in the + dialog. +3. **Propagation** — every target (and the reference structure, when one + is 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 — the same convention as 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; from there 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). Dice / HD95 between any + two structures is a click away in ◑ Compare structures. + +*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*: the 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: cohort workflows name their structures +consistently, indices and UIDs do not travel between patients. + +## Results (`src/app/motion_results.rs`) + +The results window opens when a run finishes (*Tools ▸ 📈 Motion results…* +later). Per run: the displacement-magnitude-vs-phase chart (targets × +models, the reference structure dashed red), peak-to-peak amplitude and +drift bars, the per-phase table (|d| and volume per track), the +correlation lines (r, p, significance stars, synchrony wording), the +registration-QA lines and the ITV volumes. + +**Compare with** puts a second run beside the first — dataset A vs. B, +upright vs. supine — and matches ITVs and tracks *by target name and +model*: ITV volumes side by side with the 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); the comparison export appends the second run's +rows to the same file. + +## 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 target–heart relationship of the STAR workflow: a target +defined on one patient's imaging 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. Deformable adaptation afterwards, when wanted, is the +propagation tool's job. 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 says the comparison assumes the coordinates already +correspond. + +## 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), i.e. 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/viewer.md b/docs/viewer.md index f6cefa3..5d21595 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -80,6 +80,20 @@ 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. +**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 — carries a small **Detach** button in its +top-right corner. Pressed, the window leaves the main window and becomes a +window of the operating system in its own right: it can be dragged onto a +second or third monitor, resized or maximized there, and it stays open beside +the images while the main window keeps all six viewports. **Dock** puts it +back. Several windows can be out at once, each on a different screen, and each +one reopens at the size and place it was left, on the monitor it was left on. +Which windows are detached is remembered between runs (`detached_windows` in +the settings file), so a reading room that keeps the archive on the right-hand +screen finds it there on the next start. 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. The mouse diff --git a/src/app/body_win.rs b/src/app/body_win.rs index 390e779..7cfca79 100644 --- a/src/app/body_win.rs +++ b/src/app/body_win.rs @@ -203,13 +203,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 +445,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/box_seg.rs b/src/app/box_seg.rs index d1789bc..6ab6723 100644 --- a/src/app/box_seg.rs +++ b/src/app/box_seg.rs @@ -758,13 +758,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 +964,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 cd49b55..de25b51 100644 --- a/src/app/chrome.rs +++ b/src/app/chrome.rs @@ -188,7 +188,7 @@ impl ViewerApp { ui.menu_button("Tools", |ui| { // The three segmentation engines, one block per dataset: // the same five entries, in the same order, for A and B. - let tools: [(&ToolInfo, &str); 5] = [ + let tools: [(&ToolInfo, &str); 6] = [ ( &COMBINE, "Build one structure out of others: union, intersection, \ @@ -219,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() { @@ -244,6 +250,9 @@ impl ViewerApp { 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) } @@ -270,6 +279,43 @@ 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(); + } + 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(), diff --git a/src/app/combine_win.rs b/src/app/combine_win.rs index 50ac517..f9be63e 100644 --- a/src/app/combine_win.rs +++ b/src/app/combine_win.rs @@ -357,13 +357,13 @@ impl ViewerApp { let (mut run, mut close, mut cancel) = (false, false, false); let mut move_row: Option<(usize, isize)> = None; let mut drop_row: Option = None; - egui::Window::new(COMBINE.title(slot)) - .id(egui::Id::new("combine_window")) - .collapsible(true) - .resizable(false) - .default_width(470.0) - .open(&mut open) - .show(ctx, |ui| { + 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 \ @@ -583,7 +583,8 @@ impl ViewerApp { 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 { 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..9cc8b78 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(); @@ -611,7 +612,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 b2b3a00..c33ef2a 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -51,6 +51,10 @@ impl ViewerApp { self.segvol_window(ctx); self.body_window(ctx); self.combine_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() { @@ -104,13 +108,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.", @@ -197,7 +201,8 @@ impl ViewerApp { }); } } - }); + }, + ); if browse { if let Some(dir) = Self::pick_folder("Model folder") { self.models_dir = dir.display().to_string(); @@ -227,12 +232,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(), @@ -299,7 +305,8 @@ impl ViewerApp { close_clicked = true; } }); - }); + }, + ); if apply_clicked && !close_clicked { self.apply_autoseg_selection(); } else if !open || close_clicked { @@ -320,12 +327,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 \ @@ -449,7 +460,8 @@ impl ViewerApp { ui.add_space(4.0); ui.label(msg); } - }); + }, + ); self.gen_open = open; if browse { @@ -507,13 +519,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. \ @@ -698,7 +712,8 @@ impl ViewerApp { } }); } - }); + }, + ); if !open { // Closing the window forgets everything that was scanned — the @@ -751,13 +766,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 \ @@ -877,7 +894,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/mod.rs b/src/app/mod.rs index 700c48c..bb4361b 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}; @@ -37,11 +38,15 @@ mod body_win; mod box_seg; mod chrome; mod combine_win; +mod compare_win; mod d3; +mod detach; mod dialogs; mod drr_win; mod jobs; mod models_win; +mod motion_results; +mod motion_win; mod pacs_win; mod panels; mod planar; @@ -53,6 +58,7 @@ mod seg; mod seg_engines; mod sets; mod theme; +mod transfer_win; mod tree; mod views; @@ -66,6 +72,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, @@ -900,6 +946,28 @@ pub struct ViewerApp { 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, @@ -933,6 +1001,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, @@ -959,6 +1031,12 @@ 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() @@ -1079,6 +1157,17 @@ impl ViewerApp { 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, @@ -1104,6 +1193,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, }; @@ -1145,6 +1235,7 @@ impl ViewerApp { 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) => { @@ -1298,6 +1389,11 @@ impl eframe::App for ViewerApp { { self.on_combine_done(slot, result); } + 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)) = @@ -1399,9 +1495,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..0862cef 100644 --- a/src/app/models_win.rs +++ b/src/app/models_win.rs @@ -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..705f414 --- /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 — two runs + /// can share a name, and both may be on screen. + 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..d8ebbfc --- /dev/null +++ b/src/app/motion_win.rs @@ -0,0 +1,1021 @@ +//! *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 std::sync::Arc; + +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, + group: req.group_name.clone(), + 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, + notes: Vec::new(), + }; + 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 index 3f0349d..c4e2ec4 100644 --- a/src/app/pacs_win.rs +++ b/src/app/pacs_win.rs @@ -221,12 +221,13 @@ impl ViewerApp { 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"); - egui::Window::new("🏥 PACS — patient archive") - .id(egui::Id::new("pacs_window")) - .open(&mut open) - .resizable(true) - .default_size([720.0, 520.0]) - .show(ctx, |ui| { + 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.", @@ -398,7 +399,8 @@ impl ViewerApp { if ui.button("Close").clicked() { close = true; } - }); + }, + ); if let Some(e) = expand { w.expanded = e; diff --git a/src/app/panels.rs b/src/app/panels.rs index f524ab3..07253d2 100644 --- a/src/app/panels.rs +++ b/src/app/panels.rs @@ -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, @@ -1985,6 +2288,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. @@ -2005,6 +2310,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 { @@ -2054,10 +2370,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 { @@ -2069,6 +2389,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 @@ -2202,6 +2547,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"))], } } @@ -2260,6 +2607,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..d08ee8b 100644 --- a/src/app/prompt_seg.rs +++ b/src/app/prompt_seg.rs @@ -245,13 +245,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 +369,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/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 906b699..24b4d36 100644 --- a/src/app/seg_engines.rs +++ b/src/app/seg_engines.rs @@ -58,6 +58,14 @@ pub(super) const COMBINE: ToolInfo = ToolInfo { 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. @@ -187,6 +195,13 @@ impl ViewerApp { { 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 } } @@ -315,16 +330,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(), 5, "every tool has its own glyph"); + assert_eq!(glyphs.len(), 6, "every tool has its own glyph"); } #[test] 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/fourd.rs b/src/fourd.rs new file mode 100644 index 0000000..89c2baf --- /dev/null +++ b/src/fourd.rs @@ -0,0 +1,643 @@ +//! 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 4D (10 phases + AVG)`, the default group name. + 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