From 41f13ade5e45d37fa8d47074eea8d57ec08ded55 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 19 May 2026 13:26:32 +0100 Subject: [PATCH] feat(b-values): implement parsing for multiple b-value formats and update default setting --- dicom-viewer/public/penra-test.html | 546 +++++++++++++++------------- dicom-viewer/src/App.tsx | 125 +++++-- 2 files changed, 406 insertions(+), 265 deletions(-) diff --git a/dicom-viewer/public/penra-test.html b/dicom-viewer/public/penra-test.html index be4bfc9..269f5d9 100644 --- a/dicom-viewer/public/penra-test.html +++ b/dicom-viewer/public/penra-test.html @@ -40,256 +40,314 @@ diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 947f707..d7e3b07 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -232,7 +232,7 @@ const GROUPING_RULE_DEFINITIONS: GroupingRuleDefinition[] = [ id: "contentTime", label: "Content/Acquisition time", description: "Content Time (0008,0033) / Acquisition Time (0008,0032)", - defaultEnabled: true, + defaultEnabled: false, }, { id: "echoTime", @@ -296,6 +296,66 @@ function parseNumericValueList(raw?: string | null): number[] { .filter((value) => Number.isFinite(value)); } +/** + * Extract b-value(s) from a dicomParser DataSet. Handles multiple VR encodings: + * - (0018,9087) DiffusionBValue — VR FD (double) in Enhanced MR, DS (string) in some classic MR + * - (0019,100C) GE private — VR DS + * - (0043,1039) GE legacy — VR IS/DS + * - (2001,1003) Philips private — VR FL (32-bit float) + */ +function parseBValuesFromDataSet(dataSet: any): number[] { + if (!dataSet || typeof dataSet !== "object") return []; + + const tryNumeric = (tagId: string): number | null => { + // DS/IS string form + if (typeof dataSet.string === "function") { + const raw = dataSet.string(tagId); + if (raw != null && raw !== "") { + const vals = parseNumericValueList(raw); + if (vals.length > 0 && Number.isFinite(vals[0]) && vals[0] >= 0) return vals[0]; + } + } + // FD — 64-bit double (standard DiffusionBValue in Enhanced MR) + if (typeof dataSet.double === "function") { + try { + const v = dataSet.double(tagId); + if (Number.isFinite(v) && v >= 0) return v; + } catch (_) {} + } + // FL — 32-bit float (Philips private (2001,1003)) + if (typeof dataSet.float === "function") { + try { + const v = dataSet.float(tagId); + if (Number.isFinite(v) && v >= 0) return v; + } catch (_) {} + } + return null; + }; + + // Standard (0018,9087) DiffusionBValue + const v1 = tryNumeric("x00189087"); + if (v1 !== null) return [v1]; + + // GE private (0019,100C) + const v2 = tryNumeric("x0019100c"); + if (v2 !== null) return [v2]; + + // GE legacy (0043,1039) — IS format, first value is b-value + if (typeof dataSet.string === "function") { + const raw = dataSet.string("x00431039"); + if (raw) { + const parts = parseNumericValueList(raw); + if (parts.length > 0 && Number.isFinite(parts[0]) && parts[0] >= 0) return [parts[0]]; + } + } + + // Philips private (2001,1003) — VR FL + const v3 = tryNumeric("x20011003"); + if (v3 !== null) return [v3]; + + return []; +} + function normalizeToWadouriOrExternal(imageId: string): string { if (imageId.startsWith("wadouri:") || imageId.startsWith("external:")) { return imageId; @@ -1439,19 +1499,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const cached = bValuesByBaseImageRef.current[baseUrl]; if (cached) return cached; - const parseBValuesFromDataSet = (dataSet: any): number[] => { - if (!dataSet || typeof dataSet.string !== "function") return []; - const standard = parseNumericValueList(dataSet.string("x00189087")); // DiffusionBValue - if (standard.length > 0) return standard; - const gePrivate = parseNumericValueList(dataSet.string("x0019100c")); - if (gePrivate.length > 0) return gePrivate; - const geLegacyPrivate = parseNumericValueList(dataSet.string("x00431039")); - if (geLegacyPrivate.length > 0) return geLegacyPrivate; - const privatePhilips = parseNumericValueList(dataSet.string("x20011003")); - if (privatePhilips.length > 0) return privatePhilips; - return []; - }; - try { const cacheManager = cornerstoneDICOMImageLoader?.wadouri?.dataSetCacheManager; if (cacheManager?.get) { @@ -1466,13 +1513,42 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // Continue with fetch fallback. } + // Helper: parse bytes tolerating truncation (header-only range requests) + const tryParseBytes = (bytes: Uint8Array): number[] => { + try { + return parseBValuesFromDataSet(dicomParser.parseDicom(bytes)); + } catch (e: any) { + // dicomParser throws on truncation but attaches partial dataSet + if (e?.dataSet) { + const partial = parseBValuesFromDataSet(e.dataSet); + if (partial.length > 0) return partial; + } + return []; + } + }; + + // Range request — first 64 KB is enough to find b-value tags in the header + try { + const rangeResponse = await fetch(baseUrl, { headers: { Range: "bytes=0-65535" } }); + if (rangeResponse.ok || rangeResponse.status === 206) { + const buf = await rangeResponse.arrayBuffer(); + const result = tryParseBytes(new Uint8Array(buf)); + if (result.length > 0 || rangeResponse.status === 200) { + bValuesByBaseImageRef.current[baseUrl] = result; + return result; + } + } + } catch (e) { + // Fall through to full fetch. + } + + // Full fetch fallback (only if range request failed or server doesn't support Range) try { const response = await fetch(baseUrl); - const arrayBuffer = await response.arrayBuffer(); - const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); - const fromFetch = parseBValuesFromDataSet(dataSet); - bValuesByBaseImageRef.current[baseUrl] = fromFetch; - return fromFetch; + const buf = await response.arrayBuffer(); + const result = tryParseBytes(new Uint8Array(buf)); + bValuesByBaseImageRef.current[baseUrl] = result; + return result; } catch (e) { bValuesByBaseImageRef.current[baseUrl] = []; return []; @@ -1656,8 +1732,15 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat .map((candidate) => candidate.ruleValues[ruleId]) .filter((value): value is string => !!value); const distinct = new Set(values); - // Ignore rules that are constant (or effectively unique per image) to avoid over-fragmentation. - return distinct.size > 1 && distinct.size < Math.max(2, candidates.length); + // Only activate when: + // 1. Multiple distinct values (not constant across the series) + // 2. Not effectively unique per-image (would produce a group of 1 per image) + // 3. At least 70% of images have a value for this rule — avoids fragmenting when + // only a handful of images returned metadata (e.g., b-value fetch failed for most) + const coverage = values.length / candidates.length; + return distinct.size > 1 + && distinct.size < Math.max(2, candidates.length) + && coverage >= 0.7; }) : [];