Skip to content

Commit 5cfa04d

Browse files
authored
Fix GSPS DICOMweb retrieval in grouped launches (#39)
1 parent dc376ec commit 5cfa04d

6 files changed

Lines changed: 288 additions & 54 deletions

File tree

DESIGN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Its primary purpose is consistency during development, not full architecture cov
2323

2424
## Core Invariants
2525

26-
1. Supported group sizes MUST be exactly `1`, `2`, `3`, `4`, or `8`.
26+
1. Supported primary displayable group sizes MUST be exactly `1`, `2`, `3`, `4`, or `8`; supplementary GSPS/SR objects do not count toward that total.
2727
2. Multi-view rendering paths MUST apply only to `2`, `3`, `4`, or `8`.
2828
3. Non-image DICOM objects (`DicomPathKind::Other`) and Structured Reports MUST NOT be passed to `load_dicom`.
2929
4. Structured Reports MUST load through the dedicated SR parser and single-document UI path.
@@ -34,6 +34,7 @@ Its primary purpose is consistency during development, not full architecture cov
3434
9. Streaming completion logic MUST compare image counts (not total paths including GSPS/SR).
3535
10. UI state mutations MUST stay on the main thread; workers MUST communicate through channels.
3636
11. Production diagnostics MUST use logging (`log` macros), not `println!/eprintln!`.
37+
12. DICOMweb metadata parsing MUST use top-level instance identifiers; nested reference tags inside GSPS/SR sequences MUST NOT override the owning series or instance identity.
3738

3839
## Change Rules
3940

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ cargo run -- "example-data/current-RCC.dcm" "example-data/current-LCC.dcm" "exam
7979
- `3` files: opens the mammography `1x3` layout.
8080
- `4` files: opens the mammography `2x2` layout.
8181
- `8` files: opens the mammography comparison `2x4` layout (current row + prior row).
82-
- GSPS DICOM files can be included in the same selection; overlays are available with `G` (off by default).
82+
- GSPS DICOM files can be included in the same selection, including grouped launch inputs; they act as overlays and do not count as display slots.
8383
- Structured Report (SR) DICOM files can be opened directly in a single-document view.
8484
- If images and SR objects are selected together, Perspecta opens the images first and adds each SR as a separate history entry.
8585

@@ -100,14 +100,14 @@ perspecta://open?dicomweb=http%3A%2F%2Flocalhost%3A8042&study=<StudyInstanceUID>
100100
| --- | --- |
101101
| `path`, `file` | Add one local file path |
102102
| `paths`, `files` | Add multiple local file paths (comma- or pipe-separated) |
103-
| `group` | Add one local preload group (must contain `1`, `2`, `3`, `4`, or `8` paths) |
103+
| `group` | Add one local preload group; after filtering supplementary GSPS/SR objects, each group must resolve to `1`, `2`, `3`, `4`, or `8` displayable items |
104104
| `groups` | Add multiple local preload groups separated by `;` |
105105
| `open_group` | Select which preloaded group opens first (default `0`) |
106106
| `dicomweb` | DICOMweb base URL (or full URL containing study/series/instance path segments) |
107107
| `study` | StudyInstanceUID (required for DICOMweb launch) |
108108
| `series` | SeriesInstanceUID (optional) |
109109
| `instance` | SOPInstanceUID (optional) |
110-
| `group_series` | DICOMweb grouped preload by series UID lists (each group must contain `1`, `2`, `3`, `4`, or `8`) |
110+
| `group_series` | DICOMweb grouped preload by series UID lists; each group must resolve to `1`, `2`, `3`, `4`, or `8` displayable items, while supplementary GSPS/SR objects do not count toward that total |
111111
| `user`, `password` | Optional HTTP basic auth credentials (must be provided together) |
112112
| `auth` | Alternative auth format: `username:password` (percent-encoded) |
113113

src/dicomweb.rs

Lines changed: 234 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ struct MetadataInstance {
3131
instance_number: Option<i32>,
3232
}
3333

34+
#[derive(Clone, Copy)]
35+
struct DownloadInstanceRequest<'a> {
36+
study_uid: &'a str,
37+
series_uid: Option<&'a str>,
38+
sop_class_uid: Option<&'a str>,
39+
instance_uid: &'a str,
40+
}
41+
3442
#[derive(Debug, Clone)]
3543
pub enum DicomWebDownloadResult {
3644
Single(Vec<PathBuf>),
@@ -58,9 +66,12 @@ pub fn download_dicomweb_request(
5866
let path = download_instance(
5967
&client,
6068
&base,
61-
&request.study_uid,
62-
request.series_uid.as_deref(),
63-
instance_uid,
69+
DownloadInstanceRequest {
70+
study_uid: &request.study_uid,
71+
series_uid: request.series_uid.as_deref(),
72+
sop_class_uid: None,
73+
instance_uid,
74+
},
6475
&cache_dir,
6576
auth,
6677
)?;
@@ -110,11 +121,10 @@ where
110121
let mut selected_instances_by_group = Vec::with_capacity(request.groups.len());
111122

112123
for (group_index, group_series_uids) in request.groups.iter().enumerate() {
113-
if !matches!(group_series_uids.len(), 1..=4 | 8) {
124+
if group_series_uids.is_empty() {
114125
bail!(
115-
"DICOMweb group {} has {} series UIDs; each group must contain exactly 1, 2, 3, 4, or 8 series UIDs",
116-
group_index,
117-
group_series_uids.len()
126+
"DICOMweb group {} did not include any series UIDs",
127+
group_index
118128
);
119129
}
120130
let mut reduced_by_series = Vec::<Vec<MetadataInstance>>::new();
@@ -334,9 +344,12 @@ where
334344
download_instance(
335345
client,
336346
base,
337-
study_uid,
338-
instance.series_uid.as_deref(),
339-
&instance.instance_uid,
347+
DownloadInstanceRequest {
348+
study_uid,
349+
series_uid: instance.series_uid.as_deref(),
350+
sop_class_uid: instance.sop_class_uid.as_deref(),
351+
instance_uid: &instance.instance_uid,
352+
},
340353
cache_dir,
341354
auth,
342355
)
@@ -519,17 +532,119 @@ fn split_top_level_json_objects(input: &str) -> Result<Vec<&str>> {
519532
}
520533

521534
fn first_tag_string(object: &str, tag: &str) -> Option<String> {
522-
let needle = format!("\"{tag}\"");
523-
let tag_pos = object.find(&needle)?;
524-
let tail = &object[tag_pos + needle.len()..];
525-
let value_pos = tail.find("\"Value\"")?;
526-
let after_value = &tail[value_pos + "\"Value\"".len()..];
535+
let tag_object = top_level_tag_object_slice(object, tag)?;
536+
let value_pos = tag_object.find("\"Value\"")?;
537+
let after_value = &tag_object[value_pos + "\"Value\"".len()..];
527538
let array_start = after_value.find('[')?;
528539
let after_array_start = &after_value[array_start + 1..];
529540
let first_token = parse_first_json_token(after_array_start)?;
530541
first_token_to_string(first_token)
531542
}
532543

544+
fn top_level_tag_object_slice<'a>(object: &'a str, tag: &str) -> Option<&'a str> {
545+
let bytes = object.as_bytes();
546+
let mut depth = 0usize;
547+
let mut index = 0usize;
548+
549+
while index < bytes.len() {
550+
match bytes[index] {
551+
b'"' => {
552+
let key_start = index + 1;
553+
index += 1;
554+
let mut key_escaped = false;
555+
while index < bytes.len() {
556+
let current = bytes[index];
557+
if key_escaped {
558+
key_escaped = false;
559+
} else if current == b'\\' {
560+
key_escaped = true;
561+
} else if current == b'"' {
562+
break;
563+
}
564+
index += 1;
565+
}
566+
if index >= bytes.len() {
567+
return None;
568+
}
569+
570+
if depth == 1 && object.get(key_start..index) == Some(tag) {
571+
let mut value_index = index + 1;
572+
while value_index < bytes.len() && bytes[value_index].is_ascii_whitespace() {
573+
value_index += 1;
574+
}
575+
if bytes.get(value_index) != Some(&b':') {
576+
return None;
577+
}
578+
value_index += 1;
579+
while value_index < bytes.len() && bytes[value_index].is_ascii_whitespace() {
580+
value_index += 1;
581+
}
582+
if bytes.get(value_index) != Some(&b'{') {
583+
return None;
584+
}
585+
let value_end = find_matching_object_end(bytes, value_index)?;
586+
return object.get(value_index..=value_end);
587+
}
588+
589+
index += 1;
590+
}
591+
b'{' => {
592+
depth += 1;
593+
index += 1;
594+
}
595+
b'}' => {
596+
if depth == 0 {
597+
return None;
598+
}
599+
depth -= 1;
600+
index += 1;
601+
}
602+
_ => {
603+
index += 1;
604+
}
605+
}
606+
}
607+
608+
None
609+
}
610+
611+
fn find_matching_object_end(bytes: &[u8], start: usize) -> Option<usize> {
612+
if bytes.get(start) != Some(&b'{') {
613+
return None;
614+
}
615+
616+
let mut depth = 0usize;
617+
let mut in_string = false;
618+
let mut escaped = false;
619+
620+
for (index, byte) in bytes.iter().enumerate().skip(start) {
621+
if in_string {
622+
if escaped {
623+
escaped = false;
624+
} else if *byte == b'\\' {
625+
escaped = true;
626+
} else if *byte == b'"' {
627+
in_string = false;
628+
}
629+
continue;
630+
}
631+
632+
match *byte {
633+
b'"' => in_string = true,
634+
b'{' => depth += 1,
635+
b'}' => {
636+
depth -= 1;
637+
if depth == 0 {
638+
return Some(index);
639+
}
640+
}
641+
_ => {}
642+
}
643+
}
644+
645+
None
646+
}
647+
533648
fn parse_first_json_token(value: &str) -> Option<&str> {
534649
let bytes = value.as_bytes();
535650
let mut i = 0usize;
@@ -770,12 +885,16 @@ fn mammo_sort_key(instance: &MetadataInstance) -> (u8, u8, i32, String) {
770885
fn download_instance(
771886
client: &Client,
772887
base: &str,
773-
study_uid: &str,
774-
series_uid: Option<&str>,
775-
instance_uid: &str,
888+
request: DownloadInstanceRequest<'_>,
776889
output_dir: &Path,
777890
auth: Option<(&str, &str)>,
778891
) -> Result<PathBuf> {
892+
let DownloadInstanceRequest {
893+
study_uid,
894+
series_uid,
895+
sop_class_uid,
896+
instance_uid,
897+
} = request;
779898
let mut urls = Vec::with_capacity(2);
780899
if let Some(series_uid) = series_uid {
781900
urls.push(format!(
@@ -786,12 +905,7 @@ fn download_instance(
786905
"{base}/studies/{study_uid}/instances/{instance_uid}"
787906
));
788907

789-
let accepts = [
790-
"application/dicom",
791-
"application/dicom; transfer-syntax=*",
792-
"multipart/related; type=application/dicom",
793-
"multipart/related; type=\"application/dicom\"",
794-
];
908+
let accepts = preferred_accepts_for_instance(sop_class_uid);
795909

796910
let mut last_error = None::<String>;
797911
let mut bytes = None::<Vec<u8>>;
@@ -825,6 +939,24 @@ fn download_instance(
825939
Ok(path)
826940
}
827941

942+
fn preferred_accepts_for_instance(sop_class_uid: Option<&str>) -> &'static [&'static str] {
943+
if sop_class_uid.is_some_and(is_gsps_sop_class_uid) {
944+
&[
945+
"multipart/related; type=application/dicom",
946+
"multipart/related; type=\"application/dicom\"",
947+
"application/dicom",
948+
"application/dicom; transfer-syntax=*",
949+
]
950+
} else {
951+
&[
952+
"application/dicom",
953+
"application/dicom; transfer-syntax=*",
954+
"multipart/related; type=application/dicom",
955+
"multipart/related; type=\"application/dicom\"",
956+
]
957+
}
958+
}
959+
828960
fn unwrap_dicom_multipart(body: Vec<u8>) -> Vec<u8> {
829961
match extract_dicom_from_multipart(&body) {
830962
Some(extracted) => extracted,
@@ -856,9 +988,12 @@ fn download_instances_parallel(
856988
download_instance(
857989
client,
858990
base,
859-
study_uid,
860-
instance.series_uid.as_deref(),
861-
&instance.instance_uid,
991+
DownloadInstanceRequest {
992+
study_uid,
993+
series_uid: instance.series_uid.as_deref(),
994+
sop_class_uid: instance.sop_class_uid.as_deref(),
995+
instance_uid: &instance.instance_uid,
996+
},
862997
cache_dir,
863998
auth,
864999
)
@@ -1040,6 +1175,18 @@ mod tests {
10401175
);
10411176
}
10421177

1178+
#[test]
1179+
fn extract_first_tag_string_ignores_nested_sequence_tags() {
1180+
let object = r#"{
1181+
"00081115":{"vr":"SQ","Value":[{"0020000E":{"vr":"UI","Value":["series_uid_nested"]}}]},
1182+
"0020000E":{"vr":"UI","Value":["series_uid_top_level"]}
1183+
}"#;
1184+
assert_eq!(
1185+
first_tag_string(object, TAG_SERIES_INSTANCE_UID).as_deref(),
1186+
Some("series_uid_top_level")
1187+
);
1188+
}
1189+
10431190
#[test]
10441191
fn parse_metadata_instances_trims_sop_class_uid_and_modality() {
10451192
let json = format!(
@@ -1057,6 +1204,34 @@ mod tests {
10571204
assert_eq!(instances[0].modality.as_deref(), Some("MG"));
10581205
}
10591206

1207+
#[test]
1208+
fn parse_metadata_instances_prefers_top_level_series_uid_for_gsps() {
1209+
let json = format!(
1210+
r#"[{{
1211+
"00081115":{{"vr":"SQ","Value":[{{"0020000E":{{"vr":"UI","Value":["series_uid_referenced_image"]}}}}]}},
1212+
"00080016":{{"vr":"UI","Value":["{}"]}},
1213+
"00080018":{{"vr":"UI","Value":["instance_uid_gsps"]}},
1214+
"00080060":{{"vr":"CS","Value":["PR"]}},
1215+
"0020000E":{{"vr":"UI","Value":["series_uid_gsps_actual"]}}
1216+
}}]"#,
1217+
GSPS_SOP_CLASS_UID
1218+
);
1219+
1220+
let instances = parse_metadata_instances(&json).expect("metadata should parse");
1221+
1222+
assert_eq!(instances.len(), 1);
1223+
assert_eq!(
1224+
instances[0].series_uid.as_deref(),
1225+
Some("series_uid_gsps_actual")
1226+
);
1227+
assert_eq!(instances[0].instance_uid, "instance_uid_gsps");
1228+
assert_eq!(instances[0].modality.as_deref(), Some("PR"));
1229+
assert_eq!(
1230+
instances[0].sop_class_uid.as_deref(),
1231+
Some(GSPS_SOP_CLASS_UID)
1232+
);
1233+
}
1234+
10601235
#[test]
10611236
fn normalize_base_url_adds_dicomweb_path_for_root_url() {
10621237
assert_eq!(
@@ -1281,6 +1456,38 @@ mod tests {
12811456
assert_eq!(active_group_instance_count(&mixed), Some(1));
12821457
}
12831458

1459+
#[test]
1460+
fn active_group_instance_count_ignores_supplementary_gsps() {
1461+
let instances = vec![
1462+
metadata_instance("inst_rcc", Some("CC"), Some("R"), Some(1)),
1463+
metadata_instance("inst_lcc", Some("CC"), Some("L"), Some(2)),
1464+
metadata_instance("inst_rmlo", Some("MLO"), Some("R"), Some(3)),
1465+
metadata_instance("inst_lmlo", Some("MLO"), Some("L"), Some(4)),
1466+
MetadataInstance {
1467+
instance_uid: "inst_gsps".to_string(),
1468+
sop_class_uid: Some(GSPS_SOP_CLASS_UID.to_string()),
1469+
..metadata_instance("inst_gsps", None, None, Some(5))
1470+
},
1471+
];
1472+
1473+
assert_eq!(displayable_group_image_count(&instances), 4);
1474+
assert_eq!(active_group_instance_count(&instances), Some(4));
1475+
}
1476+
1477+
#[test]
1478+
fn preferred_accepts_for_gsps_prioritize_multipart() {
1479+
let accepts = preferred_accepts_for_instance(Some(GSPS_SOP_CLASS_UID));
1480+
assert_eq!(accepts[0], "multipart/related; type=application/dicom");
1481+
assert_eq!(accepts[1], "multipart/related; type=\"application/dicom\"");
1482+
}
1483+
1484+
#[test]
1485+
fn preferred_accepts_for_images_keep_application_dicom_first() {
1486+
let accepts = preferred_accepts_for_instance(None);
1487+
assert_eq!(accepts[0], "application/dicom");
1488+
assert_eq!(accepts[1], "application/dicom; transfer-syntax=*");
1489+
}
1490+
12841491
#[test]
12851492
fn metadata_instance_kind_defaults_unknown_metadata_to_other() {
12861493
let instance = MetadataInstance {

0 commit comments

Comments
 (0)