diff --git a/Cargo.toml b/Cargo.toml index 4e02f93..e2acf7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-dicom-station" -version = "0.6.0" +version = "0.6.5" edition = "2021" description = "Fast, robust DICOM / RT DICOM viewer in pure Rust (CT/MR volumes, RTSTRUCT, RTDOSE, RTPLAN) with a three-view MPR layout" license = "MIT" diff --git a/docs/architecture.md b/docs/architecture.md index 9e9dfb4..0de3639 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,15 +41,19 @@ rust-dicom-station │ ├── Application (GUI, egui over wgpu) │ ├── Window chrome: menu bar, toolbar (W/L, presets, 3D, crosshair, reset), status bar -│ ├── Side panel: registration, simulation, per-dataset sections -│ │ (series tree, structures, segmentations, dose + isodose, plans, planar images, -│ │ spatial registrations, treatment records, warnings) +│ ├── Side panel: registration, simulation, and per dataset a DICOM tree — +│ │ patient ▶ study ▶ modality (CT/MR/US…) ▶ series, with RT structures, +│ │ segmentations, dose and plans inside their own study — plus the +│ │ dataset-level dose display, planar images, spatial registrations, +│ │ treatment records and warnings │ ├── Views: 1 × 3 or 2 × 3 (comparison) linked MPR viewports, crosshair, │ │ zoom / pan / W-L interaction, maximize, per-view caches │ ├── Floating windows: 3D structures (both datasets through the registration, │ │ per-dataset opacity, vector-field glyphs), planar image viewers │ ├── Data tree operations: rename every level (patient, study, series, sets, │ │ structures, segments, dose, plan, planar, REG, records); +│ │ Shift-click range selection; copy / move / remove / export the ticked +│ │ items from a button row or the context menu; │ │ copy / move / remove patient · study · series across datasets; │ │ create / connect / copy / move / remove RT structure sets and segmentation │ │ series; copy / move / remove single or selected structures and segments @@ -82,7 +86,8 @@ rust-dicom-station │ ├── Known-transform study generator (rigid + Gaussian deformation, registration QA) │ └── Digitally reconstructed radiographs: exact Siddon ray tracing (plastimatch) │ and interpolating ray-casting (ITK), IEC cone-beam geometry, beam's-eye view -│ from an RTPLAN beam, side-by-side difference +│ from an RTPLAN beam, side-by-side difference, filed into the data tree +│ as planar RT images with the geometry that produced them │ ├── Image registration │ ├── elastix-style rigid (6-DOF Euler, ASGD, pyramids, stochastic sampling) diff --git a/docs/drr.md b/docs/drr.md index b60a46b..4df6830 100644 --- a/docs/drr.md +++ b/docs/drr.md @@ -79,6 +79,31 @@ The two renderings are shown side by side with a shared display window radiographs are usually read dark-on-light — and a **Difference** view that maps the signed difference blue↔red about a grey zero. +## Into the data tree + +*➕ Add to dataset A/B* files the current rendering (or both, when the two +projectors were run together) under **Planar images** in that dataset's +tree. A DRR *is* an RT Image, so once it is one it inherits everything the +tree already does: its own viewer window with window/level and the correct +physical aspect ratio, renaming, and travelling with the dataset when it is +copied or moved. + +The geometry that produced it rides along as the info rows the planar viewer +lists — engine, SAD/SID, gantry and couch angles, isocentre, panel size, HU +model, threshold, sampling step (ray-cast only) and render time — so a +radiograph that has been sitting in the tree for an hour can still say +exactly what it is. Labels are `DRR Siddon · G 90° C 0°` and are made unique +on the way in, because rendering the same geometry twice is what one does +while tuning it. + +Whichever greyscale the window is showing is what gets stored: with +**Invert** on (the default) the values are mirrored about the middle of the +range so dark is high attenuation, as on a radiograph. The range itself is +unchanged either way, and the info rows say which convention was used. + +Planar images are viewer-side objects: they are not written by +*File ▶ Export dataset*, which covers CT, RTSTRUCT, SEG, RTDOSE and RTPLAN. + ## Where it fits DRR generation is a *simulation* feature, not a registration one: it shares diff --git a/docs/registration.md b/docs/registration.md index 76686f9..ce7537c 100644 --- a/docs/registration.md +++ b/docs/registration.md @@ -119,7 +119,7 @@ field and tolerate inconsistent pairs. Landmarks are placed from the interface: put the crosshair on the same anatomy in both datasets and press **➕ Add pair** in the *Landmarks* section -(turn *View ▶ Link crosshairs* off first, or both crosshairs move together). +(turn *View ▶ Sync crosshairs* off first, or both crosshairs move together). Each pair shows its displacement, and after a run, its residual. ## Running a registration diff --git a/docs/segmentation.md b/docs/segmentation.md index a083463..4498b31 100644 --- a/docs/segmentation.md +++ b/docs/segmentation.md @@ -25,8 +25,10 @@ stays intact and simply reports that it is not editable here. The sidebar *Segmentations* section shows the series as tree nodes and, below the active one, its segments: visibility, color, active selection, volume in cm³, per-stroke undo (**Ctrl+Z**), delete, and conversion to -RTSTRUCT (**→RS**, below). *All* / *None* tick every segment or none, and -the tick doubles as the selection the right-click actions work on — see +RTSTRUCT (**→RS**, below). *All* / *None* tick every segment or none, +Shift-click extends a range, and the tick doubles as the selection that the +row of actions beneath it — copy, move, remove, and *💾* to export just +those segments as a DICOM SEG file — works on. See [viewer.md](viewer.md#structures-and-segmentations-in-the-tree). ## Tools diff --git a/docs/viewer.md b/docs/viewer.md index 9a1cdc8..8e6d6ba 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -52,6 +52,12 @@ oblique acquisitions display consistently but the plane names are nominal — the anatomical edge labels (L/R/A/P/S/I) always reflect the true patient directions derived from the direction cosines. +The views tile the central area edge to edge — no gaps, no frames — and +each one carries its own **slice scrubber** along its bottom edge, drawn +over the image rather than in a strip beneath it. The plane and dataset +name in the top-left corner is white in every view; the anatomical edge +labels keep their own colour. + Each viewport carries two corner buttons (both name themselves on hover): **⟲** resets that view's zoom and pan and puts the crosshair back at the volume center — which returns that dataset's three views to their central @@ -59,7 +65,9 @@ slices — and **⛶ / ❐** maximizes the view to fill the window and restores the layout again. The toolbar holds a global **⟲** (the same reset for every view of both datasets), the **⌖** crosshair toggle (while hidden, left-click navigation is disabled entirely and slices change only by -scrolling), the **3D A / 3D B** buttons and the segmentation tools. +scrolling), the **🔗** crosshair-sync toggle next to it (shown while the +crosshair is on, active with two datasets loaded), the **3D A / 3D B** +buttons and the segmentation tools. **Window/level.** Right-drag on any view adjusts interactively (x = width, y = center); the toolbar offers the numeric fields and the @@ -73,8 +81,10 @@ Window/level is shared between datasets A and B so both CTs are windowed identically. **Status bar.** Patient coordinates, voxel indices, HU and dose (Gy and % -of the reference dose) at the crosshair; in comparison mode the readouts -for A and B are shown side by side. +of the reference dose) at the crosshair; in comparison mode both datasets +report the full set side by side, each at its own crosshair. The mouse +bindings of the tool in force are behind the **?** at the right end — hover +it to read them. **The left panel.** *View ▶ Left panel*, the **F9** key and the arrow on the window's left edge hide and show it; dragging its inner edge past the @@ -107,17 +117,48 @@ accumulated from any number of folders. *File ▶ Add DICOM folder to A/B…* merges a scanned folder into the slot without unloading what is already there; duplicates (by UID) are skipped and reported. -The left panel shows each dataset as a **Data tree** — a full DICOM -hierarchy: patient -(PatientName/PatientID) ▶ study (StudyInstanceUID, with date and -description) ▶ image series — with the displayed series marked; clicking -another series loads it. Long names, descriptions and IDs wrap over as -many lines as they need, so the panel can be dragged narrow without -cutting them off; only the section headers stay on one line. The standard reference chain is parsed and shown -as links: each structure set displays the image series its contours were -drawn on (RTReferencedSeriesSequence), each dose the plan it was computed -for (ReferencedRTPlanSequence), and each plan the structure set it was -created on (ReferencedStructureSetSequence). +The left panel shows each dataset as a full DICOM hierarchy: + +``` +Dataset A + └ Doe John (P1) patient — PatientName / PatientID + └ Study 20260827 — Planning study — StudyInstanceUID, date, description + ├ CT (2) modality + │ ├ chest (120 sl.) image series + │ └ abdomen (90 sl.) + ├ MR (1) + ├ RT structures (12/12) + │ └ ▣ Approved (12 ROIs) ▶ CT chest + ├ Segmentations (8/8) + │ └ ✎ TotalSeg (8 segments) ▶ CT chest + ├ Dose (1) + └ Plan: IMRT + Dose display · Planar images · Spatial registrations · Records · Warnings +``` + +The modality level (CT / MR / US / PT …) is one DICOM implies but does not +store as a node; it is grouped from the series' Modality, in first-seen +order. Everything that carries a StudyInstanceUID — image series, RT +structure sets, segmentation series, dose grids and plans — sits inside the +study it belongs to. An RT object whose StudyInstanceUID is blank or names +a study that is not loaded is filed under the study of the image series it +references, and failing that under the first study, because an object that +cannot be reached is worse than one shown a level from where its header +claims it lives. + +What is left below the tree is what has no study to sit under: planar +images carry no study link at all, spatial registrations and treatment +records belong to a frame of reference rather than a study, and **Dose +display** — colorwash, isodose ladder, opacity, threshold — is one setting +shared by both datasets, so it is shown once. + +The displayed series is marked; clicking another loads it. Long names, +descriptions and IDs wrap over as many lines as they need, so the panel can +be dragged narrow without cutting them off. The standard reference chain is +parsed and shown as links: each structure set and segmentation series +displays the image series it is drawn on, each dose the plan it was +computed for (ReferencedRTPlanSequence), and each plan the structure set it +was created on (ReferencedStructureSetSequence). **Right-clicking** any level of the tree — patient, study or series — opens a context menu to **rename**, **copy**, **move** or **remove** it. Copy/move @@ -152,9 +193,26 @@ to the displayed image series. **Right-clicking a series node** offers: * *✎ Rename series…*. Each item's **check box is both its visibility and its selection**, so -*All* / *None* tick everything or nothing and the right-click actions -operate on whatever is ticked. **Right-clicking a structure or segment** -offers: +*All* / *None* tick everything or nothing and the actions operate on +whatever is ticked. **Shift-click** a check box to tick — or untick — the +whole range from the last one you clicked: the span is filled with the +clicked row's new value, and rows outside it are never touched, because the +box is a visibility toggle as much as a selection and silently hiding +structures you did not point at would be worse than the convenience. + +One row carries the lot: for structures **All · None · Copy to · Move to · +🗑 · *n* selected**, and for segmentations **New · All · None · Copy to · +Move to · 🗑 · 💾 · *n* selected**. *Copy to* and *Move to* open the same +destination submenu described below; **💾** writes just the ticked segments +as a DICOM SEG file of their own. The buttons grey out when nothing is +ticked. + +The per-row buttons a segment used to carry — undo, →RS, delete — are gone: +Ctrl+Z undoes the last stroke, *Copy to ▶ an RT structure set* is what →RS +did, and **🗑** deletes whatever is ticked. + +**Right-clicking a structure or segment** offers the same set for one row or +the ticked group: * *Copy … to ▶* / *Move … to ▶* — a submenu of every structure set and segmentation series in **both** datasets, plus *➕ a new RT structure @@ -162,6 +220,11 @@ offers: ticked row acts on all ticked rows at once; right-clicking an unticked row acts on that row alone. * *🗑 Remove …* — the same single-or-selected rule. +* *💾 Export … as DICOM SEG…* (segments only) — writes the chosen segments + as a SEG series in its own right: same lattice, same referenced image + series, a fresh SOP Instance UID, and only those segments. Exporting three + organs out of twelve therefore needs no special case in the writer, and + the file reloads as an ordinary segmentation series. * *✎ Rename …* — always the row you clicked, never the whole selection. Crossing between the two kinds is a conversion, done on transfer: a @@ -198,15 +261,16 @@ study was loaded from are never modified. ![comparison mode](screenshot_comparison.png) *Two opposite breathing phases of the same 4DCT as datasets A and B, each -with its phase-specific structure set; the linked crosshair pins all six +with its phase-specific structure set; the synced crosshair pins all six views to the same patient-space point inside the tumor.* Load a second dataset (menu, tree copy/move, or two directories on the command line) and the window splits into two rows of three views — dataset A on top, dataset B below. Each dataset keeps its own structures, dose and plan panels in the sidebar; window/level and dose display settings are -shared. The crosshair is linked between the datasets through **patient -coordinates** (toggleable via *View ▶ Link crosshairs between datasets*); +shared. The crosshair is synced between the datasets through **patient +coordinates** (the toolbar's **🔗**, or *View ▶ Sync crosshairs between +datasets* — both appear only while the crosshair itself is on); when a registration is active, the link maps through the recovered transform instead — see [registration.md](registration.md). @@ -221,8 +285,10 @@ the rows. ## Planar images (DX / CR / RTIMAGE) -Digital radiographs and RT images (DRRs, portal/setup images) found in the -study folder are listed in the sidebar and open in floating viewer windows +Digital radiographs and RT images (portal/setup images) found in the study +folder — plus any DRR added from the DRR window with *➕ Add to dataset A/B* +(see [drr.md](drr.md)) — are listed in the sidebar and open in floating +viewer windows with their own window/level (opens at the DICOM default; auto, manual, or interactive right-drag exactly like the CT views), correct physical aspect ratio (imager / image-plane pixel spacing), MONOCHROME1 inversion, and the diff --git a/src/app/body_win.rs b/src/app/body_win.rs index 8a40636..2bcb52c 100644 --- a/src/app/body_win.rs +++ b/src/app/body_win.rs @@ -542,7 +542,7 @@ mod tests { fn the_tool_names_itself_like_the_others() { assert_eq!(BODY_CONTOUR.title(0), "👤 Body contour — dataset A"); assert_eq!(BODY_CONTOUR.menu_entry(1), "👤 Body-contour dataset B…"); - assert_eq!(BODY_CONTOUR.short_button(), "👤 Body…"); + assert_eq!(BODY_CONTOUR.short_button(), "👤 Body"); } #[test] diff --git a/src/app/chrome.rs b/src/app/chrome.rs index cb05b56..264e319 100644 --- a/src/app/chrome.rs +++ b/src/app/chrome.rs @@ -107,10 +107,26 @@ impl ViewerApp { { ui.close(); } - ui.checkbox(&mut self.link_studies, "Link crosshairs between datasets"); ui.separator(); ui.checkbox(&mut self.show_contours, "Contours"); ui.checkbox(&mut self.show_crosshair, "Crosshair"); + // Syncing is a property of the crosshair, so it sits under + // it and goes away with it. + if self.show_crosshair { + let both = self.slots[0].study.is_some() && self.slots[1].study.is_some(); + ui.add_enabled( + both, + egui::Checkbox::new( + &mut self.link_studies, + "Sync crosshairs between datasets", + ), + ) + .on_hover_text( + "Move one crosshair and the other follows to the same patient \ + point — through the active registration when there is one. \ + Off, each dataset is navigated on its own.", + ); + } ui.checkbox(&mut self.show_labels, "Orientation labels"); ui.checkbox(&mut self.show_isocenters, "Isocenters"); ui.separator(); @@ -317,6 +333,7 @@ impl ViewerApp { "⌖ — show / hide the crosshair; hidden, left click no \ longer navigates", ); + ui.weak("🔗 Sync — sync the crosshairs of A and B (shown while ⌖ is on)"); ui.separator(); ui.weak(format!( "rust-dicom-station {} — research / QA viewer, not a medical device", @@ -486,6 +503,27 @@ impl ViewerApp { self.show_crosshair = !self.show_crosshair; } + // Crosshair syncing: only meaningful while there is a + // crosshair, so it appears and disappears with it. + if self.show_crosshair { + let both = self.slots[0].study.is_some() && self.slots[1].study.is_some(); + if ui + .add_enabled( + both, + egui::Button::selectable(self.link_studies, "🔗 Sync"), + ) + .on_hover_text( + "Sync the crosshairs of datasets A and B: move one and the \ + other follows to the same patient point, through the active \ + registration when there is one.\n\ + Off: each dataset is navigated on its own", + ) + .clicked() + { + self.link_studies = !self.link_studies; + } + } + // Reset every view of both datasets. if ui .button("⟲") @@ -605,20 +643,19 @@ impl ViewerApp { } else { String::new() }; - if slot == self.hovered_slot || !both { - ui.monospace(format!( - "{}({:6.1},{:6.1},{:6.1})mm ijk({:3},{:3},{:3})", - prefix, - p.x, - p.y, - p.z, - c[0].round() as i64, - c[1].round() as i64, - c[2].round() as i64 - )); - } else { - ui.monospace(prefix.trim_end().to_string()); - } + // Both datasets report in full: each one's own cursor is + // a real position in its own volume, whether it was + // clicked there or followed the other one. + ui.monospace(format!( + "{}({:6.1},{:6.1},{:6.1})mm ijk({:3},{:3},{:3})", + prefix, + p.x, + p.y, + p.z, + c[0].round() as i64, + c[1].round() as i64, + c[2].round() as i64 + )); if let Some(hu) = v.get(c[0].round() as i64, c[1].round() as i64, c[2].round() as i64) { @@ -640,7 +677,10 @@ impl ViewerApp { } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.weak(match self.seg_tool { + // The readouts are what the bar is for, so the mouse + // bindings fold into a single "?" that the pointer opens + // — always the bindings of the tool in force. + let hint = match self.seg_tool { SegTool::None => { "LMB crosshair · RMB W/L · MMB pan · wheel slice · Ctrl+wheel zoom" } @@ -653,7 +693,11 @@ impl ViewerApp { SegTool::Grow => { "LMB press seed · drag up/down = grow/shrink · release commit · Esc cancel · Ctrl+Z undo" } - }); + }; + // `Sense::hover`: it looks like a button and answers the + // pointer, but there is nothing to click. + ui.add(egui::Button::new("?").small().sense(egui::Sense::hover())) + .on_hover_text(hint); }); }); }); diff --git a/src/app/drr_win.rs b/src/app/drr_win.rs index 8c0708d..44d34e1 100644 --- a/src/app/drr_win.rs +++ b/src/app/drr_win.rs @@ -122,6 +122,7 @@ impl ViewerApp { let mut run = false; let mut cancel = false; let mut set_iso = false; + let mut add_to_tree = false; let mut beam_pick: Option<(usize, usize)> = None; // Read-only facts about the datasets, gathered before the closure. @@ -343,6 +344,24 @@ impl ViewerApp { { run = true; } + if ui + .add_enabled( + !d.images.is_empty(), + egui::Button::new(format!( + "➕ Add to dataset {}", + SLOT_NAMES[d.slot] + )), + ) + .on_hover_text( + "File the rendering(s) under Planar images in the data \ + tree, with the geometry that produced them — from \ + there they open in their own viewer, rename, and \ + travel with the dataset", + ) + .clicked() + { + add_to_tree = true; + } if ui.button("Close").clicked() { close = true; } @@ -409,6 +428,9 @@ impl ViewerApp { d.params.geometry = d.params.geometry.from_beam(beam); } } + if add_to_tree { + self.add_drr_to_tree(&d); + } if running || (!close && open) { self.drr_dialog = Some(d); } @@ -422,6 +444,36 @@ impl ViewerApp { } } + /// File the current rendering(s) under the source dataset's planar + /// images. Labels are made unique on the way in, because rendering the + /// same geometry twice is exactly what one does while tuning it. + fn add_drr_to_tree(&mut self, d: &DrrDialog) { + let made: Vec = d + .images + .iter() + .map(|im| im.to_planar(&d.params, d.invert)) + .collect(); + let Some(study) = self.slots[d.slot].study.as_mut() else { + self.error = Some(format!("dataset {} is not loaded", SLOT_NAMES[d.slot])); + return; + }; + let n = made.len(); + for mut img in made { + let base = img.label.clone(); + let mut k = 2; + while study.planar_images.iter().any(|e| e.label == img.label) { + img.label = format!("{base} #{k}"); + k += 1; + } + study.planar_images.push(img); + } + self.settings_gen += 1; + self.notice = Some(format!( + "✔ {n} radiograph(s) added to dataset {} — see Planar images in the tree", + SLOT_NAMES[d.slot] + )); + } + /// Rebuild the display textures when the images or the window changed. fn refresh_drr_textures(&mut self, ctx: &egui::Context, d: &mut DrrDialog) { if d.images.is_empty() { diff --git a/src/app/mod.rs b/src/app/mod.rs index 6391b08..d6b4b95 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -610,8 +610,12 @@ enum SetAction { from: SetRef, copy: bool, }, - /// Write one segmentation series as a standalone DICOM SEG file. - ExportSeg(SetRef), + /// Write a segmentation series as a standalone DICOM SEG file. An empty + /// `items` means the whole series; otherwise only those segments. + ExportSeg { + set: SetRef, + items: Vec, + }, } /// Deferred right-click action on individual structures / segments. @@ -632,6 +636,11 @@ enum ItemAction { from: SetRef, idx: usize, }, + /// Write these segments as a DICOM SEG file of their own. + ExportSeg { + from: SetRef, + items: Vec, + }, } /// Which parts of a `LoadedStudy` a tree selection covers: the selected @@ -802,6 +811,9 @@ pub struct ViewerApp { /// A rename requested from a context menu, opened after the frame's /// borrows are released. rename_request: Option, + /// Anchor of the last check-box click in a structure / segment list, so + /// Shift-click can extend a range from it. + tick_anchor: Option<(SetRef, usize)>, /// When set, this single (slot, view) fills the whole central area. maximized: Option<(usize, usize)>, /// Invert REG matrices before applying them as the active registration. @@ -1011,6 +1023,7 @@ impl ViewerApp { item_action: None, rename: None, rename_request: None, + tick_anchor: None, maximized: None, reg_apply_invert: false, window_center: 40.0, diff --git a/src/app/panels.rs b/src/app/panels.rs index f5a60b9..93278b4 100644 --- a/src/app/panels.rs +++ b/src/app/panels.rs @@ -205,16 +205,19 @@ impl ViewerApp { pub(super) fn study_section(&mut self, ui: &mut egui::Ui, slot: usize) { // Plain header — the patient(s) always appear as tree nodes below. - let header = format!("Data tree {}", SLOT_NAMES[slot]); + let header = format!("Dataset {}", SLOT_NAMES[slot]); let ch = egui::CollapsingHeader::new(egui::RichText::new(header).strong()) .id_salt(("study_hdr", slot)) .default_open(true) .show(ui, |ui| { - self.series_selector(ui, slot); - self.structures_section(ui, slot); - self.segmentation_section(ui, slot); - self.dose_section(ui, slot); - self.plan_section(ui, slot); + // Patient ▶ study ▶ category ▶ series. Everything that + // carries a StudyInstanceUID lives inside a study node; the + // rest — planar images have no study link at all, REG objects + // and records belong to a frame of reference rather than a + // study, and the dose display settings are shared by both + // datasets — stays at dataset level below it. + self.data_tree(ui, slot); + self.dose_display_section(ui, slot); self.planar_section(ui, slot); self.reg_objects_section(ui, slot); self.records_section(ui, slot); @@ -241,233 +244,218 @@ impl ViewerApp { ui.separator(); } - /// DICOM data tree: patient ▶ study ▶ series, all visible at once. The - /// active series (the displayed volume) is marked; clicking another - /// series loads it. Right-click any level to copy / move it to the - /// other dataset or remove it. - pub(super) fn series_selector(&mut self, ui: &mut egui::Ui, slot: usize) { + /// The DICOM data tree of one dataset: patient ▶ study ▶ category ▶ + /// series, all visible at once. + /// + /// The nesting is rendered one level per method rather than as one deep + /// stack of closures: each level hands the next a fresh `&mut self` + /// reborrow that ends when the node's body returns, which is what lets a + /// node three levels down still open a dialog or start a series switch. + pub(super) fn data_tree(&mut self, ui: &mut egui::Ui, slot: usize) { + let Some(study) = self.slots[slot].study.as_ref() else { + return; + }; + let layout = tree_layout(study); + for (pi, patient) in layout.iter().enumerate() { + let me = &mut *self; + let resp = Self::wrapped_node( + ui, + ("pat_hdr", slot, pi), + true, + patient.title.clone(), + |ui| me.patient_body(ui, slot, pi, patient), + ); + let other = SLOT_NAMES[1 - slot]; + let key = patient.key.clone(); + let mut act: Option = None; + let mut rename = None; + resp.context_menu(|ui| { + if ui.button("✎ Rename patient…").clicked() { + rename = Some(RenameTarget::Patient { + slot, + key: key.clone(), + }); + ui.close(); + } + ui.separator(); + for (label, op) in [ + (format!("Copy patient to dataset {other}"), TreeOp::Copy), + (format!("Move patient to dataset {other}"), TreeOp::Move), + ] { + if ui.button(label).clicked() { + act = Some(TreeAction { + from: slot, + sel: TreeSel::Patient(key.clone()), + op, + }); + ui.close(); + } + } + ui.separator(); + if ui.button("Remove patient").clicked() { + act = Some(TreeAction { + from: slot, + sel: TreeSel::Patient(key.clone()), + op: TreeOp::Remove, + }); + ui.close(); + } + }); + if act.is_some() { + self.tree_action = act; + } + if rename.is_some() { + self.rename_request = rename; + } + } + } + + /// The studies of one patient. + fn patient_body(&mut self, ui: &mut egui::Ui, slot: usize, pi: usize, patient: &PatientNode) { + for (si, node) in patient.studies.iter().enumerate() { + let me = &mut *self; + let resp = Self::wrapped_node( + ui, + ("study_tree", slot, pi, si), + true, + node.title.clone(), + |ui| me.study_body(ui, slot, pi, si, node), + ); + let other = SLOT_NAMES[1 - slot]; + let uid = node.uid.clone(); + let mut act: Option = None; + let mut rename = None; + resp.context_menu(|ui| { + if ui.button("✎ Rename study…").clicked() { + rename = Some(RenameTarget::Study { + slot, + uid: uid.clone(), + }); + ui.close(); + } + ui.separator(); + for (label, op) in [ + (format!("Copy study to dataset {other}"), TreeOp::Copy), + (format!("Move study to dataset {other}"), TreeOp::Move), + ] { + if ui.button(label).clicked() { + act = Some(TreeAction { + from: slot, + sel: TreeSel::Study(uid.clone()), + op, + }); + ui.close(); + } + } + ui.separator(); + if ui.button("Remove study").clicked() { + act = Some(TreeAction { + from: slot, + sel: TreeSel::Study(uid.clone()), + op: TreeOp::Remove, + }); + ui.close(); + } + }); + if act.is_some() { + self.tree_action = act; + } + if rename.is_some() { + self.rename_request = rename; + } + } + } + + /// The categories of one study: the image modalities it was acquired in, + /// then the RT objects filed against it. + fn study_body( + &mut self, + ui: &mut egui::Ui, + slot: usize, + pi: usize, + si: usize, + node: &StudyNode, + ) { + for (mi, (modality, idxs)) in node.modalities.iter().enumerate() { + let me = &mut *self; + let title = format!("{modality} ({})", idxs.len()); + Self::wrapped_node(ui, ("mod", slot, pi, si, mi), true, title, |ui| { + me.series_rows(ui, slot, idxs) + }); + } + 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); + self.plan_section(ui, slot, pi, si, &node.plans); + } + + /// The image series of one modality node. + fn series_rows(&mut self, ui: &mut egui::Ui, slot: usize, idxs: &[usize]) { + let other = SLOT_NAMES[1 - slot]; let mut switch_to = None; - let mut act_series: Option = None; - let mut act_study: Option = None; - let mut act_patient: Option = None; - let mut rename: Option = None; + let mut act: Option = None; + let mut rename = None; { - let study = self.slots[slot].study.as_ref().unwrap(); + let Some(study) = self.slots[slot].study.as_ref() else { + return; + }; let active = study.active_series; - let other = SLOT_NAMES[1 - slot]; - let label = |s: &loader::SeriesInfo| { - format!( - "{} {} ({} sl.)", - s.modality, + for &i in idxs { + let Some(s) = study.series.get(i) else { + continue; + }; + let label = format!( + "{} ({} sl.)", if s.description.is_empty() { "series" } else { &s.description }, s.files.len() - ) - }; - // Distinct patients, in first-seen order. - let mut patients: Vec<&str> = Vec::new(); - for s in &study.series { - let k = s.patient_key(); - if !patients.contains(&k) { - patients.push(k); + ); + let resp = ui.add(egui::Button::selectable(i == active, label).wrap()); + if resp.clicked() && i != active { + switch_to = Some(i); } - } - for (pi, pkey) in patients.iter().enumerate() { - let pinfo = study - .series - .iter() - .find(|s| s.patient_key() == *pkey) - .unwrap(); - let pname = pinfo.patient_name.replace('^', " "); - let ptitle = if pname.is_empty() && pinfo.patient_id.is_empty() { - "Unknown patient".to_string() - } else if pname.is_empty() { - format!("Patient {}", pinfo.patient_id) - } else if pinfo.patient_id.is_empty() { - pname.clone() - } else { - format!("{} ({})", pname, pinfo.patient_id) - }; - let pch = Self::wrapped_node(ui, ("pat_hdr", slot, pi), true, ptitle, |ui| { - // Studies of this patient, in first-seen order. - let mut studies: Vec<&str> = Vec::new(); - for s in &study.series { - if s.patient_key() == *pkey && !studies.contains(&s.study_uid.as_str()) { - studies.push(&s.study_uid); - } - } - for (si, study_uid) in studies.iter().enumerate() { - let info = study - .series - .iter() - .find(|s| s.study_uid == *study_uid && s.patient_key() == *pkey) - .unwrap(); - let title = format!( - "Study {}{}", - if info.study_date.is_empty() { - format!("{}", si + 1) - } else { - info.study_date.clone() - }, - if info.study_description.is_empty() { - String::new() - } else { - format!(" — {}", info.study_description) - } - ); - let sch = Self::wrapped_node( - ui, - ("study_tree", slot, pi, si), - true, - title, - |ui| { - for (i, s) in study.series.iter().enumerate() { - if s.study_uid != *study_uid || s.patient_key() != *pkey { - continue; - } - let resp = ui.add( - egui::Button::selectable(i == active, label(s)).wrap(), - ); - if resp.clicked() && i != active { - switch_to = Some(i); - } - resp.context_menu(|ui| { - if ui.button("✎ Rename series…").clicked() { - rename = Some(RenameTarget::Series { slot, idx: i }); - ui.close(); - } - ui.separator(); - if ui - .button(format!("Copy series to dataset {other}")) - .clicked() - { - act_series = Some(TreeAction { - from: slot, - sel: TreeSel::Series(i), - op: TreeOp::Copy, - }); - ui.close(); - } - if ui - .button(format!("Move series to dataset {other}")) - .clicked() - { - act_series = Some(TreeAction { - from: slot, - sel: TreeSel::Series(i), - op: TreeOp::Move, - }); - ui.close(); - } - ui.separator(); - if ui.button("Remove series").clicked() { - act_series = Some(TreeAction { - from: slot, - sel: TreeSel::Series(i), - op: TreeOp::Remove, - }); - ui.close(); - } - }); - resp.on_hover_text(format!( - "Series UID …{}\nright-click: rename, copy / move \ - to dataset {other}, or remove", - tail(&s.uid) - )); - } - }, - ); - sch.context_menu(|ui| { - if ui.button("✎ Rename study…").clicked() { - rename = Some(RenameTarget::Study { - slot, - uid: study_uid.to_string(), - }); - ui.close(); - } - ui.separator(); - if ui - .button(format!("Copy study to dataset {other}")) - .clicked() - { - act_study = Some(TreeAction { - from: slot, - sel: TreeSel::Study(study_uid.to_string()), - op: TreeOp::Copy, - }); - ui.close(); - } - if ui - .button(format!("Move study to dataset {other}")) - .clicked() - { - act_study = Some(TreeAction { - from: slot, - sel: TreeSel::Study(study_uid.to_string()), - op: TreeOp::Move, - }); - ui.close(); - } - ui.separator(); - if ui.button("Remove study").clicked() { - act_study = Some(TreeAction { - from: slot, - sel: TreeSel::Study(study_uid.to_string()), - op: TreeOp::Remove, - }); - ui.close(); - } - }); - } - }); - pch.context_menu(|ui| { - if ui.button("✎ Rename patient…").clicked() { - rename = Some(RenameTarget::Patient { - slot, - key: pkey.to_string(), - }); + resp.context_menu(|ui| { + if ui.button("✎ Rename series…").clicked() { + rename = Some(RenameTarget::Series { slot, idx: i }); ui.close(); } ui.separator(); - if ui - .button(format!("Copy patient to dataset {other}")) - .clicked() - { - act_patient = Some(TreeAction { - from: slot, - sel: TreeSel::Patient(pkey.to_string()), - op: TreeOp::Copy, - }); - ui.close(); - } - if ui - .button(format!("Move patient to dataset {other}")) - .clicked() - { - act_patient = Some(TreeAction { - from: slot, - sel: TreeSel::Patient(pkey.to_string()), - op: TreeOp::Move, - }); - ui.close(); + for (label, op) in [ + (format!("Copy series to dataset {other}"), TreeOp::Copy), + (format!("Move series to dataset {other}"), TreeOp::Move), + ] { + if ui.button(label).clicked() { + act = Some(TreeAction { + from: slot, + sel: TreeSel::Series(i), + op, + }); + ui.close(); + } } ui.separator(); - if ui.button("Remove patient").clicked() { - act_patient = Some(TreeAction { + if ui.button("Remove series").clicked() { + act = Some(TreeAction { from: slot, - sel: TreeSel::Patient(pkey.to_string()), + sel: TreeSel::Series(i), op: TreeOp::Remove, }); ui.close(); } }); + resp.on_hover_text(format!( + "{} · series UID …{}\nright-click: rename, copy / move to dataset \ + {other}, or remove", + s.modality, + tail(&s.uid) + )); } } - if let Some(a) = act_series.or(act_study).or(act_patient) { - self.tree_action = Some(a); + if act.is_some() { + self.tree_action = act; } if rename.is_some() { self.rename_request = rename; @@ -549,7 +537,10 @@ impl ViewerApp { .on_hover_text("Write this series as one DICOM Segmentation file") .clicked() { - *out = Some(SetAction::ExportSeg(here)); + *out = Some(SetAction::ExportSeg { + set: here, + items: Vec::new(), + }); ui.close(); } } @@ -684,6 +675,20 @@ impl ViewerApp { }); } }); + if from.kind == SetKind::Segmentations { + ui.separator(); + if ui + .button(format!("💾 Export {what} as DICOM SEG…")) + .on_hover_text("Writes just these segments, as a SEG series of their own") + .clicked() + { + *out = Some(ItemAction::ExportSeg { + from, + items: items.clone(), + }); + ui.close(); + } + } ui.separator(); if ui.button(format!("🗑 Remove {what}")).clicked() { *out = Some(ItemAction::Remove { @@ -714,12 +719,83 @@ impl ViewerApp { .unwrap_or_else(|| " ▶ (unlinked)".to_string()) } - /// RT structure sets: one node per set, then the ROIs of the active one. + /// The *Copy to / Move to / Remove / Export* buttons that act on whatever + /// is ticked, added inline so they share one row with *All* / *None* — a + /// multi-item action should not have to be found by right-clicking + /// exactly the right row. + fn selection_buttons( + &self, + ui: &mut egui::Ui, + here: SetRef, + selection: &[usize], + item_act: &mut Option, + ) { + let n = selection.len(); + let what = format!("{n} ticked {}", here.kind.item_name(n)); + ui.add_enabled_ui(n > 0, |ui| { + ui.menu_button("Copy to", |ui| { + if let Some(to) = self.destination_menu(ui, here) { + *item_act = Some(ItemAction::Transfer { + from: here, + items: selection.to_vec(), + to, + copy: true, + }); + } + }) + .response + .on_hover_text(format!("Copy the {what} into another series")); + ui.menu_button("Move to", |ui| { + if let Some(to) = self.destination_menu(ui, here) { + *item_act = Some(ItemAction::Transfer { + from: here, + items: selection.to_vec(), + to, + copy: false, + }); + } + }) + .response + .on_hover_text(format!("Move the {what} into another series")); + if ui + .small_button("🗑") + .on_hover_text(format!("Remove the {what}")) + .clicked() + { + *item_act = Some(ItemAction::Remove { + from: here, + items: selection.to_vec(), + }); + } + if here.kind == SetKind::Segmentations + && ui + .small_button("💾") + .on_hover_text(format!("Write the {what} as a DICOM SEG file of their own")) + .clicked() + { + *item_act = Some(ItemAction::ExportSeg { + from: here, + items: selection.to_vec(), + }); + } + }); + ui.weak(format!("{n} selected")); + } + + /// The RT structure sets filed under one study, then the ROIs of the + /// active one. /// /// A ROI's check box is both its visibility and its selection, so *All* / - /// *None* tick everything or nothing and the right-click actions operate - /// on whatever is ticked. - pub(super) fn structures_section(&mut self, ui: &mut egui::Ui, slot: usize) { + /// *None* tick everything or nothing, Shift-click extends a range, and + /// the action row works on whatever is ticked. + pub(super) fn structures_section( + &mut self, + ui: &mut egui::Ui, + slot: usize, + pat: usize, + stu: usize, + which: &[usize], + ) { if self.slots[slot].study.is_none() { return; } @@ -730,147 +806,152 @@ impl ViewerApp { let mut new_active: Option = None; let mut set_act: Option = None; let mut item_act: Option = None; + let mut new_anchor: Option<(SetRef, usize)> = None; + let shift = ui.input(|i| i.modifiers.shift); { let me = &*self; let study = me.slots[slot].study.as_ref().unwrap(); let sets = &study.structure_sets; - let active_set = me.slots[slot] - .active_structs - .min(sets.len().saturating_sub(1)); - let n_rois = sets.get(active_set).map(|ss| ss.rois.len()).unwrap_or(0); - vis.resize(n_rois, true); + // The active set counts as belonging here only when this study + // node actually holds it; otherwise this node shows its sets but + // edits none of them. + let active_set = which + .contains(&me.slots[slot].active_structs) + .then(|| me.slots[slot].active_structs); + let n_rois = active_set + .and_then(|i| sets.get(i)) + .map(|ss| ss.rois.len()) + .unwrap_or(0); + if active_set.is_some() { + vis.resize(n_rois, true); + } let n_vis = vis.iter().filter(|v| **v).count(); - egui::CollapsingHeader::new(format!("RT structures ({n_vis}/{n_rois})")) - .id_salt(("structs", slot)) - .default_open(true) - .show(ui, |ui| { - ui.horizontal(|ui| { - if ui - .small_button("➕ New series") - .on_hover_text( - "An empty RT structure set, drawn on the displayed image series", - ) - .clicked() - { - set_act = Some(SetAction::New(SetRef { - slot, - kind: SetKind::Structures, - idx: SetRef::NEW, - })); - } - ui.weak(match sets.len() { - 0 => "no structure sets".to_string(), - 1 => "1 series".to_string(), - n => format!("{n} series"), - }); - }); - for (i, set) in sets.iter().enumerate() { - let here = SetRef { - slot, - kind: SetKind::Structures, - idx: i, - }; - let name = if set.label.is_empty() { - &set.file_name - } else { - &set.label - }; - let resp = ui.add( - egui::Button::selectable( - i == active_set, - format!( - "▣ {name} ({} ROIs){}", - set.rois.len(), - Self::series_suffix(study, &set.referenced_series_uid) - ), - ) - .wrap(), - ); - if resp.clicked() && i != active_set { - new_active = Some(i); - } - resp.context_menu(|ui| me.set_context_menu(ui, here, &mut set_act)); - resp.on_hover_text(format!( - "{}\nreferences series …{}\nright-click: connect to another image \ - series, copy / move to the other dataset, remove", - if set.file_name.is_empty() { - "created here" - } else { - &set.file_name - }, - tail(&set.referenced_series_uid) - )); - } - let Some(ss) = sets.get(active_set) else { - return; - }; - ui.separator(); - ui.horizontal(|ui| { - if ui - .small_button("All") - .on_hover_text("Show and select every structure") - .clicked() - { - vis.iter_mut().for_each(|v| *v = true); - } - if ui.small_button("None").clicked() { - vis.iter_mut().for_each(|v| *v = false); - } - ui.weak(&ss.label); - }); - let selection: Vec = vis - .iter() - .enumerate() - .filter(|(_, v)| **v) - .map(|(i, _)| i) - .collect(); + let title = match active_set { + Some(_) => format!("RT structures ({n_vis}/{n_rois})"), + None => format!("RT structures ({})", which.len()), + }; + Self::wrapped_node(ui, ("structs", slot, pat, stu), true, title, |ui| { + if ui + .small_button("New series") + .on_hover_text("An empty RT structure set, drawn on the displayed image series") + .clicked() + { + set_act = Some(SetAction::New(SetRef { + slot, + kind: SetKind::Structures, + idx: SetRef::NEW, + })); + } + for &i in which { + let Some(set) = sets.get(i) else { continue }; let here = SetRef { slot, kind: SetKind::Structures, - idx: active_set, + idx: i, }; - for (i, roi) in ss.rois.iter().enumerate() { - ui.horizontal(|ui| { - let (rect, _) = - ui.allocate_exact_size(egui::vec2(12.0, 12.0), Sense::hover()); - ui.painter().rect_filled( - rect, - 2.0, - Color32::from_rgb(roi.color[0], roi.color[1], roi.color[2]), - ); - let resp = ui.checkbox( - &mut vis[i], - format!( - "{}{}", - roi.name, - if roi.roi_type.is_empty() { - String::new() - } else { - format!(" [{}]", roi.roi_type) - } - ), - ); - resp.context_menu(|ui| { - me.item_context_menu( - ui, - here, - i, - &roi.name, - &selection, - &mut item_act, - ) - }); - resp.on_hover_text(format!( - "ROI {} · {} contour(s)\nright-click: copy / move / remove — \ - every ticked structure at once", - roi.number, - roi.contours.len() - )); - }); + let name = if set.label.is_empty() { + &set.file_name + } else { + &set.label + }; + let resp = ui.add( + egui::Button::selectable( + active_set == Some(i), + format!( + "▣ {name} ({} ROIs){}", + set.rois.len(), + Self::series_suffix(study, &set.referenced_series_uid) + ), + ) + .wrap(), + ); + if resp.clicked() && active_set != Some(i) { + new_active = Some(i); + } + resp.context_menu(|ui| me.set_context_menu(ui, here, &mut set_act)); + resp.on_hover_text(format!( + "{}\nreferences series …{}\nright-click: connect to another image \ + series, copy / move to the other dataset, remove", + if set.file_name.is_empty() { + "created here" + } else { + &set.file_name + }, + tail(&set.referenced_series_uid) + )); + } + let (Some(active_set), Some(ss)) = + (active_set, active_set.and_then(|i| sets.get(i))) + else { + return; + }; + let here = SetRef { + slot, + kind: SetKind::Structures, + idx: active_set, + }; + let selection: Vec = vis + .iter() + .enumerate() + .filter(|(_, v)| **v) + .map(|(i, _)| i) + .collect(); + ui.horizontal_wrapped(|ui| { + if ui + .small_button("All") + .on_hover_text("Show and select every structure") + .clicked() + { + vis.iter_mut().for_each(|v| *v = true); + } + if ui.small_button("None").clicked() { + vis.iter_mut().for_each(|v| *v = false); } + me.selection_buttons(ui, here, &selection, &mut item_act); }); + let anchor = me.tick_anchor.filter(|(r, _)| *r == here).map(|(_, i)| i); + for (i, roi) in ss.rois.iter().enumerate() { + ui.horizontal(|ui| { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled( + rect, + 2.0, + Color32::from_rgb(roi.color[0], roi.color[1], roi.color[2]), + ); + let resp = ui.checkbox( + &mut vis[i], + format!( + "{}{}", + roi.name, + if roi.roi_type.is_empty() { + String::new() + } else { + format!(" [{}]", roi.roi_type) + } + ), + ); + if resp.clicked() { + new_anchor = Some((here, apply_tick(&mut vis, i, shift, anchor))); + } + resp.context_menu(|ui| { + me.item_context_menu(ui, here, i, &roi.name, &selection, &mut item_act) + }); + resp.on_hover_text(format!( + "ROI {} · {} contour(s)\nShift-click: tick or untick the whole \ + range from the last one\nright-click: copy / move / remove — \ + every ticked structure at once", + roi.number, + roi.contours.len() + )); + }); + } + }); } self.slots[slot].roi_visible = vis; + if new_anchor.is_some() { + self.tick_anchor = new_anchor; + } if let Some(i) = new_active { let s = &mut self.slots[slot]; s.active_structs = i; @@ -889,9 +970,16 @@ impl ViewerApp { } } - /// Segmentation series: one node per series, then the segments of the - /// active one with the tools that edit them. - pub(super) fn segmentation_section(&mut self, ui: &mut egui::Ui, slot: usize) { + /// The segmentation series filed under one study, then the segments of + /// the active one with the tools that edit them. + pub(super) fn segmentation_section( + &mut self, + ui: &mut egui::Ui, + slot: usize, + pat: usize, + stu: usize, + which: &[usize], + ) { if self.slots[slot].study.is_none() { return; } @@ -900,28 +988,26 @@ impl ViewerApp { let running = self .running_tool(slot) .map(|(tool, p)| (tool.glyph, p.get(), p.frac())); - // (name, colour, visible, cm³, can undo) of the active series' - // segments — the editable columns live on this copy, see - // `structures_section`. - let mut rows: Vec<(String, [u8; 3], bool, f64, bool)> = { + let active_here = self.slots[slot] + .seg_series_idx() + .filter(|i| which.contains(i)); + // (name, colour, visible, cm³) of the active series' segments — the + // editable columns live on this copy, see `structures_section`. + let mut rows: Vec<(String, [u8; 3], bool, f64)> = { let s = &self.slots[slot]; let spacing = s .study .as_ref() .map(|st| st.volume.spacing) .unwrap_or([1.0; 3]); - s.segs() - .iter() - .map(|g| { - ( - g.name.clone(), - g.color, - g.visible, - g.volume_cm3(spacing), - g.can_undo(), - ) - }) - .collect() + match active_here { + Some(_) => s + .segs() + .iter() + .map(|g| (g.name.clone(), g.color, g.visible, g.volume_cm3(spacing))) + .collect(), + None => Vec::new(), + } }; let before: Vec<([u8; 3], bool)> = rows.iter().map(|r| (r.1, r.2)).collect(); let mut make_new = false; @@ -931,208 +1017,194 @@ impl ViewerApp { let mut set_all: Option = None; let mut new_active_series: Option = None; let mut activate: Option = None; - let mut undo: Option = None; - let mut delete: Option = None; - let mut to_struct: Option = None; let mut set_act: Option = None; let mut item_act: Option = None; + let mut new_anchor: Option<(SetRef, usize)> = None; + let shift = ui.input(|i| i.modifiers.shift); { let me = &*self; let study = me.slots[slot].study.as_ref().unwrap(); let series = &study.seg_series; - let active_series = me.slots[slot].seg_series_idx(); let active_seg = me.slots[slot].active_seg; let n_vis = rows.iter().filter(|r| r.2).count(); - let n_segs = active_series.map(|i| series[i].segs.len()).unwrap_or(0); - egui::CollapsingHeader::new(format!("Segmentations ({n_vis}/{n_segs})")) - .id_salt(("segs", slot)) - .default_open(true) - .show(ui, |ui| { - ui.horizontal(|ui| { + let n_segs = active_here + .and_then(|i| series.get(i)) + .map(|s| s.segs.len()) + .unwrap_or(0); + let title = match active_here { + Some(_) => format!("Segmentations ({n_vis}/{n_segs})"), + None => format!("Segmentations ({})", which.len()), + }; + Self::wrapped_node(ui, ("segs", slot, pat, stu), true, title, |ui| { + ui.horizontal_wrapped(|ui| { + if ui + .small_button("New series") + .on_hover_text( + "An empty segmentation series, drawn on the displayed image \ + series — exports as one DICOM SEG file", + ) + .clicked() + { + new_series = true; + } + for (tool, hint) in [ + ( + &BODY_CONTOUR, + "The patient outline: threshold, largest component, fill", + ), + ( + &AUTOSEG, + "Automatic multi-organ segmentation (TotalSegmentator, \ + 117 structures)", + ), + ( + &PROMPT_SEG, + "Segment whatever the crosshair points at — a box, a click \ + or a structure name (SegVol)", + ), + ( + &SLICE_PROP, + "Box a structure on one slice and follow it through the \ + stack (MedSAM2)", + ), + ] { if ui - .small_button("➕ New series") - .on_hover_text( - "An empty segmentation series, drawn on the displayed image \ - series — exports as one DICOM SEG file", - ) + .add(egui::Button::new(tool.short_button()).small()) + .on_hover_text(hint) .clicked() { - new_series = true; - } - for (tool, hint) in [ - ( - &BODY_CONTOUR, - "Outline the patient without the couch, the chair or the \ - immobilisation (EXTERNAL)", - ), - ( - &AUTOSEG, - "Automatic multi-organ segmentation (TotalSegmentator, \ - 117 structures)", - ), - ( - &PROMPT_SEG, - "Segment whatever the crosshair points at — a box, a click \ - or a structure name (SegVol)", - ), - ( - &SLICE_PROP, - "Box a structure on one slice and follow it through the \ - stack (MedSAM2)", - ), - ] { - if ui - .add(egui::Button::new(tool.short_button()).small()) - .on_hover_text(hint) - .clicked() - { - open_tool = Some(tool); - } - } - }); - for (i, sr) in series.iter().enumerate() { - let here = SetRef { - slot, - kind: SetKind::Segmentations, - idx: i, - }; - let resp = ui.add( - egui::Button::selectable( - Some(i) == active_series, - format!( - "✎ {} ({} segments){}", - sr.label, - sr.segs.len(), - Self::series_suffix(study, &sr.referenced_series_uid) - ), - ) - .wrap(), - ); - if resp.clicked() && Some(i) != active_series { - new_active_series = Some(i); + open_tool = Some(tool); } - resp.context_menu(|ui| me.set_context_menu(ui, here, &mut set_act)); - resp.on_hover_text(format!( - "{}\nright-click: connect to another image series, copy / move \ - to the other dataset, export as DICOM SEG, remove", - if sr.file_name.is_empty() { - "created here" - } else { - &sr.file_name - } - )); } - let Some(active_series) = active_series else { - ui.weak("no segmentation series yet — ➕ New series, or just paint"); - return; + }); + for &i in which { + let Some(sr) = series.get(i) else { continue }; + let here = SetRef { + slot, + kind: SetKind::Segmentations, + idx: i, }; - ui.separator(); - ui.horizontal(|ui| { - if ui - .small_button("➕ New") - .on_hover_text( - "An empty segmentation to paint with 🖌 / ✨ in the views", - ) - .clicked() - { - make_new = true; - } - if ui - .small_button("All") - .on_hover_text("Show and select every segmentation") - .clicked() - { - set_all = Some(true); + let resp = ui.add( + egui::Button::selectable( + active_here == Some(i), + format!( + "✎ {} ({} segments){}", + sr.label, + sr.segs.len(), + Self::series_suffix(study, &sr.referenced_series_uid) + ), + ) + .wrap(), + ); + if resp.clicked() && active_here != Some(i) { + new_active_series = Some(i); + } + resp.context_menu(|ui| me.set_context_menu(ui, here, &mut set_act)); + resp.on_hover_text(format!( + "{}\nright-click: connect to another image series, copy / move \ + to the other dataset, export as DICOM SEG, remove", + if sr.file_name.is_empty() { + "created here" + } else { + &sr.file_name } - if ui.small_button("None").clicked() { - set_all = Some(false); + )); + } + let Some(active_series) = active_here else { + return; + }; + // Masks of a series drawn on another image series are on that + // series' lattice — nothing here can index them. + if series[active_series].grid.dims != study.volume.dims { + ui.weak( + "drawn on another image series — display that series to see and \ + edit these segments", + ); + return; + } + if let Some((glyph, msg, frac)) = &running { + ui.horizontal(|ui| { + ui.label(*glyph); + ui.add( + egui::ProgressBar::new(*frac) + .desired_width(120.0) + .show_percentage(), + ); + if ui.small_button("Cancel").clicked() { + cancel_tool = true; } - ui.weak(&series[active_series].label); }); - // Masks of a series drawn on another image series are on - // that series' lattice — nothing here can index them. - if series[active_series].grid.dims != study.volume.dims { - ui.weak( - "drawn on another image series — display that series to see and \ - edit these segments", - ); - return; + ui.weak(msg); + } + let here = SetRef { + slot, + kind: SetKind::Segmentations, + idx: active_series, + }; + let selection: Vec = rows + .iter() + .enumerate() + .filter(|(_, r)| r.2) + .map(|(i, _)| i) + .collect(); + ui.horizontal_wrapped(|ui| { + if ui + .small_button("New") + .on_hover_text("An empty segmentation to paint with 🖌 / ✨ in the views") + .clicked() + { + make_new = true; } - if let Some((glyph, msg, frac)) = &running { - ui.horizontal(|ui| { - ui.label(*glyph); - ui.add( - egui::ProgressBar::new(*frac) - .desired_width(120.0) - .show_percentage(), - ); - if ui.small_button("Cancel").clicked() { - cancel_tool = true; - } - }); - ui.weak(msg); + if ui + .small_button("All") + .on_hover_text("Show and select every segmentation") + .clicked() + { + set_all = Some(true); } - let selection: Vec = rows - .iter() - .enumerate() - .filter(|(_, r)| r.2) - .map(|(i, _)| i) - .collect(); - let here = SetRef { - slot, - kind: SetKind::Segmentations, - idx: active_series, - }; - for (i, row) in rows.iter_mut().enumerate() { - let name = row.0.clone(); - ui.horizontal(|ui| { - ui.color_edit_button_srgb(&mut row.1); - ui.checkbox(&mut row.2, "") - .on_hover_text("Show / select this segmentation"); - let resp = ui - .add(egui::Button::selectable(i == active_seg, name.clone()).wrap()) - .on_hover_text( - "Click to make this the segmentation the tools edit", - ); - if resp.clicked() { - activate = Some(i); - } - resp.context_menu(|ui| { - me.item_context_menu(ui, here, i, &name, &selection, &mut item_act) - }); - ui.weak(format!("{:.1} cm³", row.3)); - if ui - .add_enabled(row.4, egui::Button::new("↶").small()) - .on_hover_text("Undo the last stroke (Ctrl+Z)") - .clicked() - { - undo = Some(i); - } - if ui - .small_button("→RS") - .on_hover_text( - "Convert to RTSTRUCT contours: adds a ROI to the \ - structure set, so it exports with \ - File ▶ 💾 Export", - ) - .clicked() - { - to_struct = Some(i); - } - if ui - .small_button("🗑") - .on_hover_text("Delete this segmentation") - .clicked() - { - delete = Some(i); - } - }); + if ui.small_button("None").clicked() { + set_all = Some(false); } + me.selection_buttons(ui, here, &selection, &mut item_act); }); + let anchor = me.tick_anchor.filter(|(r, _)| *r == here).map(|(_, i)| i); + // The check boxes are edited on `ticks` so a Shift-range can + // reach rows the loop has already drawn. + let mut ticks: Vec = rows.iter().map(|r| r.2).collect(); + for (i, row) in rows.iter_mut().enumerate() { + let name = row.0.clone(); + ui.horizontal(|ui| { + ui.color_edit_button_srgb(&mut row.1); + let tick = ui.checkbox(&mut ticks[i], "").on_hover_text( + "Show / select this segmentation\nShift-click: tick or untick \ + the whole range from the last one", + ); + if tick.clicked() { + new_anchor = Some((here, apply_tick(&mut ticks, i, shift, anchor))); + } + let resp = ui + .add(egui::Button::selectable(i == active_seg, name.clone()).wrap()) + .on_hover_text("Click to make this the segmentation the tools edit"); + if resp.clicked() { + activate = Some(i); + } + resp.context_menu(|ui| { + me.item_context_menu(ui, here, i, &name, &selection, &mut item_act) + }); + ui.weak(format!("{:.1} cm³", row.3)); + }); + } + for (row, on) in rows.iter_mut().zip(ticks) { + row.2 = on; + } + }); } if let Some(v) = set_all { rows.iter_mut().for_each(|r| r.2 = v); } + if new_anchor.is_some() { + self.tick_anchor = new_anchor; + } let edited: Vec<(usize, [u8; 3], bool)> = rows .iter() .enumerate() @@ -1177,28 +1249,6 @@ impl ViewerApp { p.cancel(); } } - if let Some(i) = undo { - let s = &mut self.slots[slot]; - if let Some(seg) = s.segs_mut().and_then(|g| g.get_mut(i)) { - seg.undo_last(); - } - } - if let Some(i) = delete { - let s = &mut self.slots[slot]; - let active = s.active_seg; - if let Some(segs) = s.segs_mut() { - if i < segs.len() { - segs.remove(i); - let n = segs.len(); - if active >= n { - s.active_seg = n.saturating_sub(1); - } - } - } - } - if let Some(i) = to_struct { - self.seg_to_rtstruct(slot, i, "ORGAN"); - } if set_act.is_some() { self.set_action = set_act; } @@ -1207,19 +1257,20 @@ impl ViewerApp { } } - pub(super) fn dose_section(&mut self, ui: &mut egui::Ui, slot: usize) { - let n_doses = self.slots[slot] - .study - .as_ref() - .map(|s| s.doses.len()) - .unwrap_or(0); - if n_doses == 0 { - // No RTDOSE in this study — show nothing. + /// The RTDOSE grids filed under one study: which one is displayed, and + /// what it is. How dose is *drawn* is a display setting shared by both + /// datasets, so it lives in [`Self::dose_display_section`] instead. + pub(super) fn dose_section( + &mut self, + ui: &mut egui::Ui, + slot: usize, + pat: usize, + stu: usize, + which: &[usize], + ) { + if which.is_empty() { return; } - let mut mode = self.dose_mode; - let mut opacity = self.dose_opacity; - let mut threshold = self.dose_threshold_pct; let mut rename: Option = None; { let StudySlot { @@ -1228,25 +1279,31 @@ impl ViewerApp { dose_reference, .. } = &mut self.slots[slot]; - let doses = &study.as_ref().unwrap().doses; - let plans = &study.as_ref().unwrap().plans; - let dose_hdr = egui::CollapsingHeader::new("Dose") - .id_salt(("dose", slot)) - .default_open(true) - .show(ui, |ui| { - if doses.len() > 1 { - let mut sel = (*active_dose).min(doses.len() - 1); - egui::ComboBox::from_id_salt(("dose_sel", slot)) - .width(230.0) - .selected_text(&doses[sel].label) - .show_ui(ui, |ui| { - for (i, d) in doses.iter().enumerate() { - ui.selectable_value(&mut sel, i, &d.label); - } - }); - *active_dose = sel; + let Some(study) = study.as_ref() else { return }; + let doses = &study.doses; + let plans = &study.plans; + let mut picked = (*active_dose).min(doses.len().saturating_sub(1)); + let hdr = Self::wrapped_node( + ui, + ("dose", slot, pat, stu), + true, + format!("Dose ({})", which.len()), + |ui| { + for &i in which { + let Some(d) = doses.get(i) else { continue }; + let resp = + ui.add(egui::Button::selectable(i == picked, d.label.clone()).wrap()); + if resp.clicked() { + picked = i; + } + resp.on_hover_text(format!( + "{} max {:.2} {}", + d.summation_type, + d.max_dose, + d.units.to_lowercase() + )); } - let d = &doses[(*active_dose).min(doses.len() - 1)]; + let Some(d) = doses.get(picked) else { return }; ui.weak(format!( "{} max {:.2} {}", d.summation_type, @@ -1269,20 +1326,6 @@ impl ViewerApp { )); } } - - egui::ComboBox::from_id_salt(("dose_mode", slot)) - .selected_text(mode.label()) - .show_ui(ui, |ui| { - for m in [ - DoseMode::Off, - DoseMode::Colorwash, - DoseMode::Isodose, - DoseMode::Both, - ] { - ui.selectable_value(&mut mode, m, m.label()); - } - }); - ui.horizontal(|ui| { ui.label("Reference"); ui.add( @@ -1295,17 +1338,12 @@ impl ViewerApp { *dose_reference = d.max_dose; } }); - ui.add(egui::Slider::new(&mut opacity, 0.0..=1.0).text("Opacity")); - ui.add(egui::Slider::new(&mut threshold, 0.0..=100.0).text("Threshold %")); - }); - let sel = (*active_dose).min(doses.len() - 1); - dose_hdr.header_response.context_menu(|ui| { - if ui - .button("✎ Rename this dose…") - .on_hover_text(format!("Renames '{}'", doses[sel].label)) - .clicked() - { - rename = Some(RenameTarget::Dose { slot, idx: sel }); + }, + ); + *active_dose = picked; + hdr.context_menu(|ui| { + if ui.button("✎ Rename this dose…").clicked() { + rename = Some(RenameTarget::Dose { slot, idx: picked }); ui.close(); } }); @@ -1313,50 +1351,77 @@ impl ViewerApp { if rename.is_some() { self.rename_request = rename; } - self.dose_mode = mode; - self.dose_opacity = opacity; - self.dose_threshold_pct = threshold; + } - // Isodose levels are shared; show them once (under the first slot - // that has dose). - let first_dose_slot = (0..2).find(|&s| { + /// How dose is drawn — colorwash, isodose lines, opacity, threshold and + /// the isodose ladder. Shared by both datasets, so it is shown once, at + /// dataset level, under the first dataset that actually has dose. + pub(super) fn dose_display_section(&mut self, ui: &mut egui::Ui, slot: usize) { + let first = (0..2).find(|&s| { self.slots[s] .study .as_ref() .is_some_and(|st| !st.doses.is_empty()) }); - if first_dose_slot == Some(slot) { - egui::CollapsingHeader::new("Isodose levels (% of reference)") - .id_salt("iso_levels") - .default_open(true) - .show(ui, |ui| { - for l in &mut self.iso_levels { - ui.horizontal(|ui| { - let (rect, _) = - ui.allocate_exact_size(egui::vec2(12.0, 12.0), Sense::hover()); - ui.painter().rect_filled(rect, 2.0, l.color); - ui.checkbox(&mut l.on, format!("{:.0}%", l.pct)); - }); + if first != Some(slot) { + return; + } + let mut mode = self.dose_mode; + let mut opacity = self.dose_opacity; + let mut threshold = self.dose_threshold_pct; + Self::wrapped_node(ui, ("dose_display", slot), true, "Dose display", |ui| { + egui::ComboBox::from_id_salt(("dose_mode", slot)) + .selected_text(mode.label()) + .show_ui(ui, |ui| { + for m in [ + DoseMode::Off, + DoseMode::Colorwash, + DoseMode::Isodose, + DoseMode::Both, + ] { + ui.selectable_value(&mut mode, m, m.label()); } }); - } + ui.add(egui::Slider::new(&mut opacity, 0.0..=1.0).text("Opacity")); + ui.add(egui::Slider::new(&mut threshold, 0.0..=100.0).text("Threshold %")); + ui.weak("Isodose levels (% of reference)"); + for l in &mut self.iso_levels { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(rect, 2.0, l.color); + ui.checkbox(&mut l.on, format!("{:.0}%", l.pct)); + }); + } + }); + self.dose_mode = mode; + self.dose_opacity = opacity; + self.dose_threshold_pct = threshold; } - pub(super) fn plan_section(&mut self, ui: &mut egui::Ui, slot: usize) { + pub(super) fn plan_section( + &mut self, + ui: &mut egui::Ui, + slot: usize, + pat: usize, + stu: usize, + which: &[usize], + ) { + if which.is_empty() { + return; + } let mut rename: Option = None; { let Some(study) = &self.slots[slot].study else { return; }; - if study.plans.is_empty() { - // No RTPLAN in this study — show nothing. - return; - } - for (pi, plan) in study.plans.iter().enumerate() { + for (n, &pi) in which.iter().enumerate() { + let Some(plan) = study.plans.get(pi) else { + continue; + }; let plan_hdr = Self::wrapped_node( ui, - ("plan", slot, pi), - pi == 0, + ("plan", slot, pat, stu, pi), + n == 0, format!( "Plan: {}", if plan.label.is_empty() { @@ -1400,7 +1465,7 @@ impl ViewerApp { } } if !plan.beams.is_empty() { - egui::Grid::new(("beam_grid", slot, pi)) + egui::Grid::new(("beam_grid", slot, pat, stu, pi)) .striped(true) .min_col_width(10.0) .show(ui, |ui| { @@ -1885,3 +1950,342 @@ impl ViewerApp { }); } } + +/// One study node of the data tree: which of the dataset's objects belong +/// under it, as indices into the parallel arrays of [`LoadedStudy`]. +pub(super) struct StudyNode { + uid: String, + title: String, + /// Image series of this study, grouped by modality in first-seen order — + /// the CT / MR / US level DICOM implies but does not store as a node. + modalities: Vec<(String, Vec)>, + structs: Vec, + segs: Vec, + doses: Vec, + plans: Vec, +} + +/// One patient node: the studies filed under them. +pub(super) struct PatientNode { + key: String, + title: String, + studies: Vec, +} + +/// Sort a dataset into the patient ▶ study ▶ category ▶ series tree. +/// +/// Series carry the patient and study they belong to, so those two levels +/// fall straight out of them. The RT objects carry a StudyInstanceUID as +/// well, but not always a usable one — a set built in the application from a +/// series that had none, or a file written by a tool that left it blank. An +/// object whose study is not in the tree is therefore filed under the study +/// of the image series it references, and failing that under the first study +/// 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 { + // Patients and their studies, both in first-seen order. + let mut patients: Vec = Vec::new(); + for se in &study.series { + let key = se.patient_key().to_string(); + if !patients.iter().any(|p| p.key == key) { + let name = se.patient_name.replace('^', " "); + let title = match (name.is_empty(), se.patient_id.is_empty()) { + (true, true) => "Unknown patient".to_string(), + (true, false) => format!("Patient {}", se.patient_id), + (false, true) => name.clone(), + (false, false) => format!("{} ({})", name, se.patient_id), + }; + patients.push(PatientNode { + key, + title, + studies: Vec::new(), + }); + } + } + for (si, se) in study.series.iter().enumerate() { + let p = patients + .iter_mut() + .find(|p| p.key == se.patient_key()) + .expect("every patient was collected above"); + let node = match p.studies.iter_mut().position(|s| s.uid == se.study_uid) { + Some(i) => &mut p.studies[i], + None => { + let n = p.studies.len() + 1; + let title = format!( + "Study {}{}", + if se.study_date.is_empty() { + n.to_string() + } else { + se.study_date.clone() + }, + if se.study_description.is_empty() { + String::new() + } else { + format!(" — {}", se.study_description) + } + ); + p.studies.push(StudyNode { + uid: se.study_uid.clone(), + title, + modalities: Vec::new(), + structs: Vec::new(), + segs: Vec::new(), + doses: Vec::new(), + plans: Vec::new(), + }); + p.studies.last_mut().expect("just pushed") + } + }; + let modality = if se.modality.is_empty() { + "Other".to_string() + } else { + se.modality.clone() + }; + match node.modalities.iter_mut().find(|(m, _)| *m == modality) { + Some((_, v)) => v.push(si), + None => node.modalities.push((modality, vec![si])), + } + } + + // Where an RT object goes, by the rule in the doc comment above. + let series_study = |uid: &str| -> Option { + study + .series + .iter() + .find(|se| se.uid == uid) + .map(|se| se.study_uid.clone()) + }; + // The (patient, study) address of every study node, so the placement + // below can look one up without borrowing `patients` while it fills them. + let index: Vec<(String, usize, usize)> = patients + .iter() + .enumerate() + .flat_map(|(pi, p)| { + p.studies + .iter() + .enumerate() + .map(move |(si, s)| (s.uid.clone(), pi, si)) + }) + .collect(); + let place = |own: &str, referenced: &str| -> Option<(usize, usize)> { + let find = |uid: &str| { + index + .iter() + .find(|(u, _, _)| u == uid) + .map(|(_, pi, si)| (*pi, *si)) + }; + find(own) + .or_else(|| series_study(referenced).and_then(|u| find(&u))) + .or_else(|| index.first().map(|(_, pi, si)| (*pi, *si))) + }; + for (i, ss) in study.structure_sets.iter().enumerate() { + if let Some((pi, si)) = place(&ss.study_uid, &ss.referenced_series_uid) { + patients[pi].studies[si].structs.push(i); + } + } + for (i, sr) in study.seg_series.iter().enumerate() { + if let Some((pi, si)) = place(&sr.study_uid, &sr.referenced_series_uid) { + patients[pi].studies[si].segs.push(i); + } + } + for (i, d) in study.doses.iter().enumerate() { + if let Some((pi, si)) = place(&d.study_uid, "") { + patients[pi].studies[si].doses.push(i); + } + } + for (i, p) in study.plans.iter().enumerate() { + if let Some((pi, si)) = place(&p.study_uid, "") { + patients[pi].studies[si].plans.push(i); + } + } + patients +} + +/// Apply a check-box click to a visibility/selection list, extending from +/// `anchor` when Shift is held, and return the new anchor. +/// +/// egui has already toggled `vis[i]` by the time this runs, so the clicked +/// row's *new* value is what the span is filled with: tick one row and +/// Shift-tick a later one and everything between turns on; untick and +/// Shift-untick and it all turns off. Rows outside the span are never +/// touched — the box is a visibility toggle as much as a selection, and +/// silently hiding structures the user did not point at would be worse than +/// any convenience gained. +fn apply_tick(vis: &mut [bool], i: usize, shift: bool, anchor: Option) -> usize { + if let (true, Some(a)) = (shift, anchor) { + if a < vis.len() && i < vis.len() { + let v = vis[i]; + let (lo, hi) = if a <= i { (a, i) } else { (i, a) }; + vis[lo..=hi].iter_mut().for_each(|x| *x = v); + } + } + i +} + +#[cfg(test)] +mod tick_tests { + use super::apply_tick; + + /// Without Shift a click is what egui already did to that one row. + #[test] + fn a_plain_click_touches_only_its_own_row() { + let mut v = vec![false, true, false, false]; + assert_eq!(apply_tick(&mut v, 2, false, Some(0)), 2, "anchor moves"); + assert_eq!(v, vec![false, true, false, false]); + } + + /// Shift fills the span with the clicked row's new value, in either + /// direction, and leaves everything outside it alone. + #[test] + fn shift_fills_the_span_from_the_anchor() { + let mut v = vec![false; 6]; + v[4] = true; // egui toggled the clicked row on + assert_eq!(apply_tick(&mut v, 4, true, Some(1)), 4); + assert_eq!(v, vec![false, true, true, true, true, false]); + + // Backwards, and unticking: the span follows the clicked row's value. + let mut v = vec![true; 6]; + v[1] = false; + apply_tick(&mut v, 1, true, Some(3)); + assert_eq!(v, vec![true, false, false, false, true, true]); + } + + /// A stale anchor (the list shrank, or it belongs to nothing) must not + /// panic or reach outside the list. + #[test] + fn a_stale_anchor_is_ignored() { + let mut v = vec![false, true]; + assert_eq!(apply_tick(&mut v, 1, true, Some(9)), 1); + assert_eq!(v, vec![false, true]); + let mut v = vec![false, true]; + assert_eq!(apply_tick(&mut v, 1, true, None), 1); + assert_eq!(v, vec![false, true]); + } +} + +#[cfg(test)] +mod layout_tests { + use super::*; + use crate::dicomseg::SegSeries; + use crate::geometry::Vec3; + use crate::rtstruct::StructureSet; + + fn series(uid: &str, modality: &str, patient: &str, study: &str) -> loader::SeriesInfo { + loader::SeriesInfo { + uid: uid.into(), + modality: modality.into(), + description: format!("{uid} desc"), + patient_id: patient.into(), + patient_name: format!("{patient}^Name"), + study_uid: study.into(), + study_date: "20260827".into(), + study_description: String::new(), + files: vec![std::path::PathBuf::from(format!("{uid}.dcm"))], + } + } + + fn structset(sop: &str, series_uid: &str, study: &str) -> StructureSet { + StructureSet { + label: sop.into(), + frame_of_reference_uid: String::new(), + sop_instance_uid: sop.into(), + study_uid: study.into(), + referenced_series_uid: series_uid.into(), + file_name: String::new(), + rois: Vec::new(), + } + } + + fn study() -> LoadedStudy { + let vol = Arc::new(Volume { + data: vec![0], + dims: [1, 1, 1], + spacing: [1.0; 3], + 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: String::new(), + min_value: 0, + max_value: 0, + }); + LoadedStudy { + meta: loader::PatientMeta::default(), + series: vec![ + series("ct1", "CT", "P1", "st1"), + series("ct2", "CT", "P1", "st1"), + series("mr1", "MR", "P1", "st1"), + series("ct3", "CT", "P1", "st2"), + series("us1", "US", "P2", "st3"), + ], + active_series: 0, + volume: vol.clone(), + structure_sets: vec![ + structset("ss1", "ct1", "st1"), + // No study of its own: it must follow the series it references. + structset("ss2", "ct3", ""), + // Neither: it must still be reachable, under the first study. + structset("ss3", "", ""), + ], + seg_series: vec![SegSeries::new( + "segs".into(), + vol.grid(), + "us1".into(), + "st3".into(), + )], + doses: Vec::new(), + plans: Vec::new(), + planar_images: Vec::new(), + registrations: Vec::new(), + treat_records: Vec::new(), + warnings: Vec::new(), + default_window: (40.0, 400.0), + } + } + + /// Patients, their studies and the modality level DICOM implies but does + /// not store, all in first-seen order. + #[test] + fn the_tree_nests_patient_study_modality_series() { + let layout = tree_layout(&study()); + assert_eq!(layout.len(), 2, "two patients"); + assert_eq!(layout[0].key, "P1"); + assert_eq!(layout[0].studies.len(), 2); + assert_eq!(layout[1].studies.len(), 1); + + let st1 = &layout[0].studies[0]; + let mods: Vec<&str> = st1.modalities.iter().map(|(m, _)| m.as_str()).collect(); + assert_eq!(mods, vec!["CT", "MR"], "one node per modality, first seen"); + assert_eq!(st1.modalities[0].1, vec![0, 1], "both CT series under CT"); + assert_eq!(st1.modalities[1].1, vec![2]); + assert_eq!(layout[1].studies[0].modalities[0].0, "US"); + } + + /// An RT object with an incomplete StudyInstanceUID must still land + /// somewhere reachable rather than disappearing from the tree. + #[test] + fn rt_objects_fall_back_to_their_series_then_to_the_first_study() { + let layout = tree_layout(&study()); + assert_eq!( + layout[0].studies[0].structs, + vec![0, 2], + "own study, then the orphan" + ); + assert_eq!( + layout[0].studies[1].structs, + vec![1], + "no study of its own — filed under the study of the series it references" + ); + assert_eq!( + layout[1].studies[0].segs, + vec![0], + "the segmentation series follows its own study" + ); + let total: usize = layout + .iter() + .flat_map(|p| p.studies.iter()) + .map(|s| s.structs.len()) + .sum(); + assert_eq!(total, 3, "every structure set is reachable exactly once"); + } +} diff --git a/src/app/reg_panel.rs b/src/app/reg_panel.rs index 525fe97..cdc6ba0 100644 --- a/src/app/reg_panel.rs +++ b/src/app/reg_panel.rs @@ -450,7 +450,7 @@ impl ViewerApp { .on_hover_text( "Take the crosshair of each dataset as one pair. Put \ both crosshairs on the same anatomy first — and turn \ - off View ▶ Link crosshairs, or they move together.", + off View ▶ Sync crosshairs, or they move together.", ) .clicked() { diff --git a/src/app/seg_engines.rs b/src/app/seg_engines.rs index ee167fa..4ea9a97 100644 --- a/src/app/seg_engines.rs +++ b/src/app/seg_engines.rs @@ -75,7 +75,7 @@ impl ToolInfo { /// `🤖 Auto…`, the small sidebar button. pub fn short_button(&self) -> String { let short = self.verb.split(['-', ' ']).next().unwrap_or(self.verb); - format!("{} {short}…", self.glyph) + format!("{} {short}", self.glyph) } } @@ -298,9 +298,9 @@ mod tests { ); assert_eq!(PROMPT_SEG.menu_entry(1), "🧠 Prompt-segment dataset B…"); assert_eq!(SLICE_PROP.menu_entry(0), "⏩ Propagate through dataset A…"); - assert_eq!(AUTOSEG.short_button(), "🤖 Auto…"); - assert_eq!(PROMPT_SEG.short_button(), "🧠 Prompt…"); - assert_eq!(SLICE_PROP.short_button(), "⏩ Propagate…"); + assert_eq!(AUTOSEG.short_button(), "🤖 Auto"); + 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…"); let mut glyphs = vec![ AUTOSEG.glyph, diff --git a/src/app/sets.rs b/src/app/sets.rs index 0feba34..dd4e782 100644 --- a/src/app/sets.rs +++ b/src/app/sets.rs @@ -69,7 +69,7 @@ impl ViewerApp { SetAction::Rename(r) => self.rename_request = Some(RenameTarget::Set(r)), SetAction::Connect(r, uid) => self.connect_set(r, &uid), SetAction::Transfer { from, copy } => self.transfer_set(from, copy), - SetAction::ExportSeg(r) => self.export_seg_series(r), + SetAction::ExportSeg { set, items } => self.export_seg_series(set, &items), } } @@ -206,16 +206,39 @@ impl ViewerApp { self.settings_gen += 1; } - /// Write one segmentation series as a standalone DICOM SEG file. - fn export_seg_series(&mut self, r: SetRef) { + /// Write a segmentation series — or just the chosen segments of it — as a + /// standalone DICOM SEG file. + /// + /// A subset is written as a series in its own right: same lattice, same + /// referenced image series, its own SOP Instance UID, and only the + /// segments asked for. That is exactly what a SEG file is, so exporting + /// three organs out of twelve needs no special case in the writer. + fn export_seg_series(&mut self, r: SetRef, items: &[usize]) { let Some(study) = self.slots[r.slot].study.as_ref() else { return; }; - let Some(ser) = study.seg_series.get(r.idx) else { + let Some(full) = study.seg_series.get(r.idx) else { return; }; + let subset; + let ser = if items.is_empty() { + full + } else { + let mut picked = items.to_vec(); + picked.sort_unstable(); + picked.dedup(); + let mut s = full.clone(); + s.segs = picked + .iter() + .filter_map(|&i| full.segs.get(i).cloned()) + .collect(); + s.sop_instance_uid = crate::dicom_export::new_uid(); + subset = s; + &subset + }; if ser.segs.iter().all(|s| s.count == 0) { - self.error = Some("this segmentation series is empty — nothing to write".into()); + self.error = + Some("nothing to write — the chosen segmentation(s) have no voxels".into()); return; } let stem: String = ser @@ -223,9 +246,18 @@ impl ViewerApp { .chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); + let suffix = if items.is_empty() { + String::new() + } else { + format!("_{}_of_{}", ser.segs.len(), full.segs.len()) + }; let Some(path) = rfd::FileDialog::new() - .set_title("Save the segmentation series as DICOM SEG") - .set_file_name(format!("SEG_{stem}.dcm")) + .set_title(if items.is_empty() { + "Save the segmentation series as DICOM SEG" + } else { + "Save the selected segmentation(s) as DICOM SEG" + }) + .set_file_name(format!("SEG_{stem}{suffix}.dcm")) .save_file() else { return; @@ -276,6 +308,7 @@ impl ViewerApp { ItemAction::Rename { from, idx } => { self.rename_request = Some(RenameTarget::Item { set: from, idx }) } + ItemAction::ExportSeg { from, items } => self.export_seg_series(from, &items), ItemAction::Transfer { from, items, diff --git a/src/app/views.rs b/src/app/views.rs index e91c103..f39f402 100644 --- a/src/app/views.rs +++ b/src/app/views.rs @@ -60,12 +60,14 @@ impl ViewerApp { } let two_rows = self.comparison; let full = ui.available_rect_before_wrap(); - let row_gap = 6.0; + // The views tile the central area edge to edge: every pixel + // spent on a gap is a pixel of image not shown, and each view + // names itself in its own corner. let n_rows = if two_rows { 2.0 } else { 1.0 }; - let row_h = (full.height() - (n_rows - 1.0) * row_gap) / n_rows; + let row_h = full.height() / n_rows; for row in 0..(n_rows as usize) { - let y0 = full.top() + row as f32 * (row_h + row_gap); + let y0 = full.top() + row as f32 * row_h; let row_rect = Rect::from_min_size( Pos2::new(full.left(), y0), Vec2::new(full.width(), row_h), @@ -80,10 +82,9 @@ impl ViewerApp { } pub(super) fn study_row(&mut self, ui: &mut egui::Ui, slot: usize, row_rect: Rect) { - let gap = 4.0; - let col_w = (row_rect.width() - 2.0 * gap) / 3.0; + let col_w = row_rect.width() / 3.0; for idx in 0..3 { - let x0 = row_rect.left() + idx as f32 * (col_w + gap); + let x0 = row_rect.left() + idx as f32 * col_w; let col = Rect::from_min_size( Pos2::new(x0, row_rect.top()), Vec2::new(col_w, row_rect.height()), @@ -92,16 +93,21 @@ impl ViewerApp { } } - /// One viewport plus its slice slider inside `cell` (used both by the - /// three-in-a-row layout and by the maximized single-view layout). + /// The slice slider lies *inside* the viewport, along its bottom edge — + /// a scrubber over the image rather than a strip of window furniture + /// under it. Returns the rectangle it occupies so the viewport can hand + /// pointer activity there over to it. + fn slider_strip(cell: Rect) -> Rect { + Rect::from_min_max( + Pos2::new(cell.left() + 10.0, cell.bottom() - 18.0), + Pos2::new(cell.right() - 10.0, cell.bottom() - 5.0), + ) + } + + /// One viewport filling `cell`, with its slice slider drawn on top of it + /// (used both by the three-in-a-row layout and by the maximized + /// single-view layout). pub(super) fn view_cell(&mut self, ui: &mut egui::Ui, slot: usize, idx: usize, cell: Rect) { - let slider_h = 26.0; - let view_rect = Rect::from_min_max(cell.min, Pos2::new(cell.max.x, cell.max.y - slider_h)); - let slider_rect = Rect::from_min_max( - Pos2::new(cell.min.x + 6.0, cell.max.y - slider_h + 2.0), - Pos2::new(cell.max.x - 6.0, cell.max.y - 2.0), - ); - self.one_view(ui, slot, idx, view_rect); let max_slice = self.slots[slot] .study .as_ref() @@ -111,12 +117,29 @@ impl ViewerApp { .saturating_sub(1) }) .unwrap_or(0); + let slider_rect = Self::slider_strip(cell); + self.one_view(ui, slot, idx, cell, max_slice > 0); if max_slice > 0 { + // A dark band under the slider keeps it readable over bright + // anatomy without hiding much of it. + ui.painter().rect_filled( + Rect::from_min_max( + Pos2::new(cell.left(), slider_rect.top() - 4.0), + Pos2::new(cell.right(), cell.bottom()), + ), + 0.0, + Color32::from_black_alpha(90), + ); let mut slice = self.slots[slot].views[idx].slice.min(max_slice); + // A scrubber spans what it scrubs: the slider's own default width + // would leave it a stub in the corner of a wide view. + let saved = ui.spacing().slider_width; + ui.spacing_mut().slider_width = slider_rect.width(); let resp = ui.put( slider_rect, egui::Slider::new(&mut slice, 0..=max_slice).show_value(false), ); + ui.spacing_mut().slider_width = saved; if resp.changed() { self.slots[slot].views[idx].slice = slice; } @@ -206,7 +229,14 @@ impl ViewerApp { } // -- One viewport ----------------------------------------------------- - pub(super) fn one_view(&mut self, ui: &mut egui::Ui, slot: usize, idx: usize, rect: Rect) { + pub(super) fn one_view( + &mut self, + ui: &mut egui::Ui, + slot: usize, + idx: usize, + rect: Rect, + has_slider: bool, + ) { let ctx = ui.ctx().clone(); let plane = self.slots[slot].views[idx].plane; @@ -432,52 +462,54 @@ impl ViewerApp { } } - // Crosshair. - if self.show_crosshair { - // ---- the MedSAM2 prompt ---- - if medsam2_show { - if let Some(b) = &self.medsam2.prompt { - if b.plane == plane { - let here = b.slice == view.slice; - let base = Color32::from_rgb(255, 205, 60); - let col = if here { - base - } else { - // On other slices the box is a reminder of where it - // is, not something to grab. - Color32::from_rgba_unmultiplied(255, 205, 60, 70) - }; - let (lo, hi) = b.rect(); - let r = Rect::from_two_pos(px_to_screen(lo), px_to_screen(hi)); - painter.rect_stroke( - r, - 0.0, - Stroke::new(if here { 2.0 } else { 1.0 }, col), - egui::StrokeKind::Middle, - ); - if here { - for c in b.corners() { - painter.rect_filled( - Rect::from_center_size(px_to_screen(c), Vec2::splat(7.0)), - 1.0, - col, - ); - } - for (p, include) in &b.points { - let at = px_to_screen(*p); - let c = if *include { - Color32::from_rgb(90, 220, 130) - } else { - Color32::from_rgb(240, 95, 95) - }; - painter.circle_filled(at, 4.5, c); - painter.circle_stroke(at, 4.5, Stroke::new(1.0, Color32::BLACK)); - } + // The MedSAM2 prompt: the box belongs to the tool, not to the + // crosshair, so it stays visible with ⌖ off — which is exactly how + // it is drawn, with left-click navigation out of the way. + if medsam2_show { + if let Some(b) = &self.medsam2.prompt { + if b.plane == plane { + let here = b.slice == view.slice; + let base = Color32::from_rgb(255, 205, 60); + let col = if here { + base + } else { + // On other slices the box is a reminder of where it + // is, not something to grab. + Color32::from_rgba_unmultiplied(255, 205, 60, 70) + }; + let (lo, hi) = b.rect(); + let r = Rect::from_two_pos(px_to_screen(lo), px_to_screen(hi)); + painter.rect_stroke( + r, + 0.0, + Stroke::new(if here { 2.0 } else { 1.0 }, col), + egui::StrokeKind::Middle, + ); + if here { + for c in b.corners() { + painter.rect_filled( + Rect::from_center_size(px_to_screen(c), Vec2::splat(7.0)), + 1.0, + col, + ); + } + for (p, include) in &b.points { + let at = px_to_screen(*p); + let c = if *include { + Color32::from_rgb(90, 220, 130) + } else { + Color32::from_rgb(240, 95, 95) + }; + painter.circle_filled(at, 4.5, c); + painter.circle_stroke(at, 4.5, Stroke::new(1.0, Color32::BLACK)); } } } } + } + // Crosshair. + if self.show_crosshair { let cp = vol.voxel_to_plane_pixel(plane, slot_state.cursor); let c = px_to_screen([cp[0] as f32, cp[1] as f32]); let col = Color32::from_rgba_unmultiplied(120, 255, 120, 110); @@ -565,11 +597,9 @@ impl ViewerApp { Align2::LEFT_TOP, title, FontId::proportional(14.0), - if slot == 0 { - Color32::from_rgb(255, 170, 60) - } else { - Color32::from_rgb(120, 200, 255) - }, + // Both datasets read the same: the title says which one this + // is, so it needs no colour code of its own. + Color32::WHITE, ); painter.text( rect.right_top() + Vec2::new(-6.0, 4.0), @@ -598,7 +628,10 @@ impl ViewerApp { lc, ); painter.text( - Pos2::new(rect.center().x, rect.bottom() - 6.0), + Pos2::new( + rect.center().x, + rect.bottom() - if has_slider { 26.0 } else { 6.0 }, + ), Align2::CENTER_BOTTOM, lbl(dy), f.clone(), @@ -642,8 +675,15 @@ impl ViewerApp { let fit_rect = Rect::from_min_size(Pos2::new(max_rect.left() - bsize.x - 4.0, by), bsize); let (pointer_pos, any_click) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.any_click())); + // The slice scrubber along the bottom edge belongs to the same + // family: the pointer is over a control, not over the image. + let slider_rect = if has_slider { + Self::slider_strip(rect).expand2(egui::vec2(0.0, 5.0)) + } else { + Rect::NOTHING + }; let over_buttons = pointer_pos - .map(|p| max_rect.contains(p) || fit_rect.contains(p)) + .map(|p| max_rect.contains(p) || fit_rect.contains(p) || slider_rect.contains(p)) .unwrap_or(false); // ---- interaction ---- diff --git a/src/drr.rs b/src/drr.rs index a224417..08e8358 100644 --- a/src/drr.rs +++ b/src/drr.rs @@ -39,6 +39,8 @@ use anyhow::{bail, Result}; use rayon::prelude::*; +use crate::extras::PlanarImage; + use crate::geometry::Vec3; use crate::progress::{ProgressSink, CANCELLED}; use crate::rtplan::BeamInfo; @@ -64,6 +66,14 @@ impl Engine { } } + /// The engine's name without its lineage, for compact labels. + pub fn short(self) -> &'static str { + match self { + Engine::Siddon => "Siddon", + Engine::RayCast => "Ray-cast", + } + } + pub fn hint(self) -> &'static str { match self { Engine::Siddon => { @@ -290,6 +300,83 @@ impl DrrImage { self.dims[0], self.dims[1], self.spacing[0], self.min, self.max, self.elapsed_secs ) } + + /// File this rendering as a planar image, so it can live in the data tree + /// beside the DX / CR / RTIMAGE the study came with — a DRR *is* an RT + /// Image, and once it is one it inherits everything the tree already + /// does: its own viewer window, renaming, and travelling with the study + /// when it is copied or moved. + /// + /// `invert` stores the greyscale the way the DRR window is showing it. + /// A line integral is large where attenuation is large, which on a + /// radiograph reads as *dark*; inverting keeps what lands in the tree + /// looking like what was on screen. The geometry that produced the image + /// is carried along as the info rows the planar viewer lists. + pub fn to_planar(&self, params: &DrrParams, invert: bool) -> PlanarImage { + let g = ¶ms.geometry; + // The inversion maps [min, max] onto itself, so the range and the + // default window are the same either way. + let data: Vec = if invert { + self.pixels + .iter() + .map(|v| self.min + self.max - v) + .collect() + } else { + self.pixels.clone() + }; + let mut info = vec![ + ("Source".into(), format!("DRR — {}", self.engine.label())), + ( + "Geometry".into(), + format!( + "SAD {:.0} mm · SID {:.0} mm · gantry {:.1}° · couch {:.1}°", + g.sad, g.sid, g.gantry_deg, g.couch_deg + ), + ), + ( + "Isocentre".into(), + format!( + "({:.1}, {:.1}, {:.1}) mm", + g.isocenter.x, g.isocenter.y, g.isocenter.z + ), + ), + ( + "Panel".into(), + format!("{:.0} × {:.0} mm", g.panel_mm[0], g.panel_mm[1]), + ), + ("HU model".into(), params.hu.label().into()), + ("Threshold".into(), format!("{:.0} HU", params.threshold_hu)), + ]; + if self.engine == Engine::RayCast { + info.push(("Step".into(), format!("{:.2} mm", params.step_mm))); + } + info.push(("Render time".into(), format!("{:.2} s", self.elapsed_secs))); + info.push(( + "Greyscale".into(), + if invert { + "inverted — dark is high attenuation, as on a radiograph".into() + } else { + "line integral — bright is high attenuation".into() + }, + )); + PlanarImage { + label: format!( + "DRR {} · G {:.0}° C {:.0}°", + self.engine.short(), + g.gantry_deg, + g.couch_deg + ), + modality: "RTIMAGE".into(), + rows: self.dims[1], + cols: self.dims[0], + spacing: self.spacing, + data, + min_value: self.min, + max_value: self.max, + window: ((self.min + self.max) * 0.5, (self.max - self.min).max(1.0)), + info, + } + } } /// How two renderings of the same geometry differ — the reason for having @@ -607,6 +694,70 @@ mod tests { } } + /// A hand-built rendering, to check the tree hand-off without paying + /// for a projection. + fn fake_image(engine: Engine) -> DrrImage { + DrrImage { + pixels: vec![0.0, 1.0, 3.0, 4.0, 2.0, 0.0], + dims: [3, 2], + spacing: [0.5, 0.25], + min: 0.0, + max: 4.0, + engine, + elapsed_secs: 1.5, + } + } + + /// The planar image a DRR becomes must describe the same picture: same + /// raster, same physical size, same value range — and the greyscale the + /// window was showing. + #[test] + fn a_rendering_becomes_a_planar_image() { + let params = DrrParams { + geometry: Geometry { + gantry_deg: 90.0, + couch_deg: 0.0, + ..Geometry::default() + }, + engine: Engine::Siddon, + ..DrrParams::for_volume(&slab(8, 1.0, 3..5, 0)) + }; + let im = fake_image(Engine::Siddon); + + let plain = im.to_planar(¶ms, false); + assert_eq!((plain.cols, plain.rows), (3, 2), "columns × rows"); + assert_eq!(plain.spacing, [0.5, 0.25]); + assert_eq!(plain.modality, "RTIMAGE"); + assert_eq!(plain.data, im.pixels, "values are the line integral itself"); + assert_eq!((plain.min_value, plain.max_value), (0.0, 4.0)); + assert_eq!(plain.window, (2.0, 4.0), "centre and width of the range"); + assert!(plain.label.contains("Siddon") && plain.label.contains("G 90°")); + + // Inverting mirrors the values about the middle of the range and + // leaves the range itself alone. + let flipped = im.to_planar(¶ms, true); + assert_eq!(flipped.data, vec![4.0, 3.0, 1.0, 0.0, 2.0, 4.0]); + assert_eq!( + (flipped.min_value, flipped.max_value), + (im.min, im.max), + "the range is unchanged by the inversion" + ); + + // The geometry rides along, and the sampling step is only meaningful + // for the engine that has one. + let keys: Vec<&str> = plain.info.iter().map(|(k, _)| k.as_str()).collect(); + assert!(keys.contains(&"Geometry") && keys.contains(&"Isocentre")); + assert!(!keys.contains(&"Step"), "the exact tracer has no step"); + let cast = fake_image(Engine::RayCast).to_planar( + &DrrParams { + engine: Engine::RayCast, + ..params + }, + true, + ); + assert!(cast.info.iter().any(|(k, _)| k == "Step")); + } + #[test] fn the_beam_frame_follows_the_iec_convention() { let g = Geometry {