Skip to content

feat(viewer): add GSPS overlay support with toggle, docs, and tests#23

Merged
timcogan merged 6 commits intomasterfrom
feat/gsps-overlay-toggle
Mar 5, 2026
Merged

feat(viewer): add GSPS overlay support with toggle, docs, and tests#23
timcogan merged 6 commits intomasterfrom
feat/gsps-overlay-toggle

Conversation

@timcogan
Copy link
Owner

@timcogan timcogan commented Mar 5, 2026

Summary by CodeRabbit

  • New Features

    • GSPS overlays supported: overlays attach to matching images, render in single- and multi-view, and can be toggled with G (off by default); overlays do not consume display slots.
  • Documentation

    • README and launch-options docs updated with GSPS guidance, usage notes, overlay behavior, and keyboard shortcuts.
  • Chores

    • Added a development dependency to support development/testing.

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6e572562-0daf-46d8-ac4a-a0fc33b51210

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds GSPS (Grayscale Softcopy Presentation State) support: public GSPS types and parsing in src/dicom.rs, integration, rendering, UI/keyboard toggle and state in src/app.rs, documentation updates, tests, and a dev-dependency in Cargo.toml.

Changes

Cohort / File(s) Summary
DICOM Parsing & Types
src/dicom.rs
Introduces GSPS public API and parsing: GSPS_SOP_CLASS_UID, DicomPathKind, GspsUnits, GspsGraphic, GspsOverlay; is_gsps_sop_class_uid, classify_dicom_path, load_gsps_overlays; GSPS parsing helpers and tests. Adds DicomImage fields sop_instance_uid and gsps_overlay.
Application Logic & Rendering
src/app.rs
Adds PreparedLoadPaths, pending GSPS overlay state, overlay merge/attach by SOP UID, visibility flag and toggle (G key), rendering helpers (gsps_point_to_screen, draw_gsps_overlay), lifecycle resets, and tests. Updates load/history flows to incorporate GSPS overlays.
Documentation & Website
README.md, website/content/docs/launch-options.md
Documents GSPS overlay behavior and controls (G toggle), notes about including GSPS files with images, and updates last_updated and keyboard shortcuts.
Build Configuration
Cargo.toml
Adds [dev-dependencies] with dicom-core = "0.7" for development/testing.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant UI
  participant App
  participant DicomModule
  participant FS as FileSystem

  User->>UI: select files / press G
  UI->>App: load_paths(paths) / toggle_gsps()
  App->>FS: read file bytes
  FS-->>App: file bytes
  App->>DicomModule: classify_dicom_path(file)
  alt GSPS file
    App->>DicomModule: load_gsps_overlays(file)
    DicomModule-->>App: map<SOP UID, GspsOverlay>
    App->>App: merge/attach overlays to images by SOP UID
  else Image file
    App->>DicomModule: load_dicom(file)
    DicomModule-->>App: DicomImage (with sop_instance_uid)
  end
  App->>UI: update state (pending overlays, active overlay)
  UI->>App: toggle_gsps()
  App->>UI: render(image + overlay if visible)
  UI-->>User: display result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through bytes to find each UID,
Lines and points in grayscale glide,
Press G and watch the overlays play,
Polylines bounce while pixels sway,
A tiny rabbit cheers — hooray! 🎨🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main changes: GSPS overlay support with toggle functionality, documentation, and tests across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app.rs`:
- Around line 1137-1151: The loop currently discards GSPS by pushing only
prepared.image_paths into preload_groups; instead preserve GSPS by storing the
full prepared result (or both image_paths and gsps_paths) so overlays are
available when preloading non-active groups—update the preload_groups collection
to hold the prepared struct (from prepare_load_paths) and modify
preload_non_active_groups_into_history to accept and use the GSPS/overlay paths;
keep load_selected_paths usage for the active group unchanged but ensure
prepared (with GSPS) is passed into the history preload call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a490649f-5fe0-4c35-a9d8-a3bff9adca8a

📥 Commits

Reviewing files that changed from the base of the PR and between 09def0e and 87e60ed.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • Cargo.toml
  • README.md
  • src/app.rs
  • src/dicom.rs
  • website/content/docs/launch-options.md

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app.rs`:
- Around line 1231-1232: When new streamed GSPS are merged into
pending_gsps_overlays (the code path that updates pending_gsps_overlays),
iterate the already-loaded images in the relevant mammo_group and call
Self::attach_matching_gsps_overlay(&mut image, &self.pending_gsps_overlays) for
each so late-arriving GSPS get attached to existing viewports; specifically, add
a loop that finds the same collection of images you use when inserting
pending.image and invoke attach_matching_gsps_overlay for each image instance
after merging GSPS into pending_gsps_overlays.
- Around line 1397-1399: The completion logic uses active_group_len (derived
from self.dicomweb_active_group_paths) but streamed_count only increments for
non-GSPS images, causing mismatches when groups contain GSPS; update the logic
where active_group_len is computed (used around active_group_len / lines
~1553-1560) to count only image paths that will be streamed (exclude GSPS) so it
matches the streamed_count behavior, and ensure the same filtered count is used
in any spawned thread that references dicomweb_active_group_paths (e.g., the
block started from mammo_load_sender thread spawn at dicomweb_active_group_paths
push) so completion/cleanup conditions compare like-for-like.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d2b6aad5-9511-4c3d-90de-2c853c0001fb

📥 Commits

Reviewing files that changed from the base of the PR and between 87e60ed and bc19fbc.

📒 Files selected for processing (1)
  • src/app.rs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app.rs (1)

1561-1582: ⚠️ Potential issue | 🟡 Minor

Normalize open_group before preloading non-active groups.

active_group_len is read safely via .get(open_group), but Line 1579 still passes raw open_group into preload_non_active_groups_into_history. If open_group is out of bounds, non-active filtering can be wrong and may preload the active group unintentionally.

💡 Proposed fix
                     DicomWebDownloadResult::Grouped { groups, open_group } => {
                         let prepared_groups = groups
                             .iter()
                             .map(|group| Self::prepare_load_paths(group.clone()))
                             .collect::<Vec<_>>();
-                        let active_group_len = prepared_groups
-                            .get(open_group)
+                        let active_group = open_group.min(prepared_groups.len().saturating_sub(1));
+                        let active_group_len = prepared_groups
+                            .get(active_group)
                             .map(|group| group.image_paths.len())
                             .unwrap_or(0);
@@
-                            self.preload_non_active_groups_into_history(
-                                &prepared_groups,
-                                open_group,
-                            );
+                            self.preload_non_active_groups_into_history(
+                                &prepared_groups,
+                                active_group,
+                            );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app.rs` around lines 1561 - 1582, The code reads active_group_len using
prepared_groups.get(open_group) but then passes the raw open_group into
preload_non_active_groups_into_history, which can be out-of-bounds and cause the
active group to be treated as non-active; validate/normalize open_group against
prepared_groups before calling preload_non_active_groups_into_history (e.g.,
compute let validated_open = if prepared_groups.get(open_group).is_some() {
open_group } else { 0 } or similar) and use validated_open in the call to
preload_non_active_groups_into_history, keeping the existing prepared_groups and
Self::prepare_load_paths usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/app.rs`:
- Around line 1561-1582: The code reads active_group_len using
prepared_groups.get(open_group) but then passes the raw open_group into
preload_non_active_groups_into_history, which can be out-of-bounds and cause the
active group to be treated as non-active; validate/normalize open_group against
prepared_groups before calling preload_non_active_groups_into_history (e.g.,
compute let validated_open = if prepared_groups.get(open_group).is_some() {
open_group } else { 0 } or similar) and use validated_open in the call to
preload_non_active_groups_into_history, keeping the existing prepared_groups and
Self::prepare_load_paths usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47b33fb9-2203-4985-adb1-b8227a52bdc9

📥 Commits

Reviewing files that changed from the base of the PR and between bc19fbc and c7d3170.

📒 Files selected for processing (1)
  • src/app.rs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app.rs`:
- Around line 281-283: The match arm incorrectly treats Ok(DicomPathKind::Other)
as an image and pushes it into prepared.image_paths; update the match in the
code handling DicomPathKind so only Ok(DicomPathKind::Image) (and Err(_) if
intended) add to prepared.image_paths and ensure Ok(DicomPathKind::Other) is
excluded (e.g., handled separately or ignored) so non-image DICOM objects are
not fed into load_dicom; locate the match that references DicomPathKind and
prepared.image_paths and adjust the arms accordingly.
- Around line 1384-1396: Currently merge_gsps_overlays +
attach_matching_gsps_overlay only update live mammo_group images (via
pending_gsps_overlays), so cached HistoryGroupViewportData.image can miss late
GSPS overlays; update sync_current_state_to_history to, after merging
pending_gsps_overlays, iterate the active group's history entries and call
attach_matching_gsps_overlay(&mut history_entry.ViewportData.image,
&self.pending_gsps_overlays) for each HistoryGroupViewportData to keep cached
images in sync with live mammo_group; ensure you call merge_gsps_overlays before
applying overlays so pending_gsps_overlays contains the latest combined
overlays.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 03cd1080-ddf6-49fe-b550-61d79ae02aac

📥 Commits

Reviewing files that changed from the base of the PR and between c7d3170 and e55ff66.

📒 Files selected for processing (1)
  • src/app.rs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/app.rs (1)

1386-1436: ⚠️ Potential issue | 🟠 Major

Skip non-image streamed objects before spawning image decode.

At Line 1386, only Gsps is filtered; every other kind currently flows into load_dicom. That includes DicomPathKind::Other, which can fail decode and abort an otherwise valid stream. Mirror prepare_load_paths behavior here and only spawn decode for Image (optionally Err(_) fallback if intentional), while ignoring/logging Other.

💡 Suggested fix
-                    if classify_dicom_path(&path).is_ok_and(|kind| kind == DicomPathKind::Gsps) {
-                        match load_gsps_overlays(&path) {
+                    match classify_dicom_path(&path) {
+                        Ok(DicomPathKind::Gsps) => match load_gsps_overlays(&path) {
                             Ok(overlays) => {
                                 Self::merge_gsps_overlays(
                                     &mut self.pending_gsps_overlays,
                                     overlays,
                                 );
                                 for viewport in
                                     self.mammo_group.iter_mut().filter_map(Option::as_mut)
                                 {
                                     Self::attach_matching_gsps_overlay(
                                         &mut viewport.image,
                                         &self.pending_gsps_overlays,
                                     );
                                 }
                                 self.sync_current_state_to_history();
                             }
                             Err(err) => {
                                 eprintln!(
                                     "Could not parse streamed GSPS {}: {err:#}",
                                     path.display()
                                 );
                             }
-                        }
-                    } else {
+                        },
+                        Ok(DicomPathKind::Image) | Err(_) => {
                         self.dicomweb_active_group_paths.push(path.clone());
                         if let Some(sender) = self.mammo_load_sender.as_ref().cloned() {
                             thread::spawn(move || {
                                 let result = match load_dicom(&path) {
                                     Ok(image) => Ok(PendingLoad { path, image }),
                                     Err(err) => Err(format!(
                                         "Error opening streamed DICOM {}: {err:#}",
                                         path.display()
                                     )),
                                 };
                                 let _ = sender.send(result);
                             });
                         } else {
                             self.status_line =
                                 "Streaming multi-view load channel not available.".to_string();
                             self.mammo_group.clear();
                             self.mammo_load_receiver = None;
                             self.mammo_load_sender = None;
                             self.history_pushed_for_active_group = false;
                             self.cine_mode = false;
                             self.dicomweb_active_group_paths.clear();
                             self.dicomweb_active_pending_paths.clear();
                             self.dicomweb_active_group_expected = None;
                             self.dicomweb_active_path_receiver = None;
                         }
-                    }
+                        }
+                        Ok(DicomPathKind::Other) => {
+                            eprintln!("Skipping streamed non-image DICOM object {}", path.display());
+                        }
+                    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app.rs` around lines 1386 - 1436, The code currently treats any non-GSPS
path as an image and spawns load_dicom; update the else branch to re-check
classify_dicom_path(&path) and only spawn the decode thread when the result is
DicomPathKind::Image (optionally allow Err(_) if you want to attempt unknowns),
and for DicomPathKind::Other skip spawning and log/ignore the path; modify the
block handling self.mammo_load_sender (and the branch that currently calls
load_dicom) to match on classify_dicom_path(&path) and only call load_dicom for
Image, leaving GSPS handling unchanged and mirroring the prepare_load_paths
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/app.rs`:
- Around line 1386-1436: The code currently treats any non-GSPS path as an image
and spawns load_dicom; update the else branch to re-check
classify_dicom_path(&path) and only spawn the decode thread when the result is
DicomPathKind::Image (optionally allow Err(_) if you want to attempt unknowns),
and for DicomPathKind::Other skip spawning and log/ignore the path; modify the
block handling self.mammo_load_sender (and the branch that currently calls
load_dicom) to match on classify_dicom_path(&path) and only call load_dicom for
Image, leaving GSPS handling unchanged and mirroring the prepare_load_paths
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bc7f8e19-6736-4da2-8baa-698e5d907392

📥 Commits

Reviewing files that changed from the base of the PR and between e55ff66 and 5762c49.

📒 Files selected for processing (1)
  • src/app.rs

@timcogan timcogan merged commit 79173ad into master Mar 5, 2026
7 checks passed
@timcogan timcogan deleted the feat/gsps-overlay-toggle branch March 5, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant