diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 4b8e17e..11210cf 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -95,6 +95,7 @@ type StackEntry = { studyInstanceUID?: string; caseId?: string | number; seriesGroups?: StackSeriesGroup[]; + groupingSource?: "declared" | "derived"; }; const { MouseBindings, KeyboardBindings } = csToolsEnums; @@ -940,14 +941,17 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat ? seriesEntry.imageIds : (Array.isArray(seriesEntry?.i) ? seriesEntry.i : []); const normalizedIds = dedupeImageIds(rawIds.map(normalizeToWadouriOrExternal)); - const rawLabel = typeof seriesEntry?.name === "string" ? seriesEntry.name : ""; + const rawLabel = typeof seriesEntry?.label === "string" + ? seriesEntry.label + : (typeof seriesEntry?.name === "string" ? seriesEntry.name : ""); const label = rawLabel.trim() || `Series ${seriesIdx + 1}`; return { - key: String(seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`), + key: String(seriesEntry?.key || seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`), label, imageIds: normalizedIds, seriesInstanceUID: seriesEntry?.seriesInstanceUID, bValue: Number.isFinite(Number(seriesEntry?.bValue)) ? Number(seriesEntry.bValue) : null, + groupValues: seriesEntry?.groupValues, } as StackSeriesGroup; }) .filter((group: StackSeriesGroup) => group.imageIds.length > 0); @@ -956,6 +960,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat allStacks.push({ imageIds: dedupeImageIds(declaredGroups.flatMap((g) => g.imageIds)), seriesGroups: declaredGroups, + groupingSource: "declared", ...meta, }); continue; @@ -964,7 +969,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const normalizedImageIds = dedupeImageIds(imageIdsRaw.map(normalizeToWadouriOrExternal)); if (normalizedImageIds.length > 0) { - allStacks.push({ imageIds: normalizedImageIds, seriesGroups: [], ...meta }); + allStacks.push({ imageIds: normalizedImageIds, seriesGroups: [], groupingSource: "derived", ...meta }); } } @@ -976,14 +981,23 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat let firstGroups = firstStack.seriesGroups || []; let firstImageIds = firstStack.imageIds; if (firstGroups.length === 0) { - firstImageIds = await expandMultiframeImageIds(firstStack.imageIds); - await setLoadedImageIdsAndCacheMeta(firstImageIds, { force: true }); - firstGroups = await deriveSeriesGroupsFromImageIds(firstImageIds); - allStacks[0] = { ...firstStack, imageIds: firstImageIds, seriesGroups: firstGroups }; - setAvailableStacks([...allStacks]); + if (autoCacheStack) { + appLogger.debug("[init] autoCacheStack true: expanding multiframe and fetching metadata"); + firstImageIds = await expandMultiframeImageIds(firstStack.imageIds); + await setLoadedImageIdsAndCacheMeta(firstImageIds, { force: true }); + firstGroups = await deriveSeriesGroupsFromImageIds(firstImageIds); + allStacks[0] = { ...firstStack, imageIds: firstImageIds, seriesGroups: firstGroups, groupingSource: "derived" }; + setAvailableStacks([...allStacks]); + } else { + appLogger.debug("[init] autoCacheStack false: skipping all probes/expansion, relying on server grouping"); + } } const firstSeries = firstGroups[0]?.imageIds || firstImageIds; - await setLoadedImageIdsAndCacheMeta(firstSeries); + if (autoCacheStack) { + await setLoadedImageIdsAndCacheMeta(firstSeries, { force: true }); + } else { + appLogger.debug("[init] autoCacheStack false: not fetching metadata for firstSeries"); + } setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map((_, idx) => (idx === 0 ? (firstSeries || []) : []))); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstGroups)); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); @@ -1005,20 +1019,79 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const stackObj = availableStacks[stackIdx]; if (!stackObj) return; - // Expand multiframe IDs lazily when the stack is actually loaded. - const stack = await expandMultiframeImageIds(stackObj.imageIds || []); + const hasDeclaredGroups = stackObj.groupingSource === "declared" && (stackObj.seriesGroups || []).length > 0; + if (!autoCacheStack && hasDeclaredGroups && (stackObj.imageIds || []).length > 1) { + // Strict lazy mode: never probe, expand, or fetch metadata if grouping is declared and autoCacheStack is false + appLogger.debug("[handleLoadStack] autoCacheStack false & declared groups: skipping all probes/expansion, using server grouping only"); + const stack = dedupeImageIds((stackObj.imageIds || []).map(normalizeToWadouriOrExternal)); + const seriesGroups = stackObj.seriesGroups || []; + setAvailableStacks(prev => { + const next = [...prev]; + if (!next[stackIdx]) return prev; + next[stackIdx] = { + ...next[stackIdx], + seriesGroups, + imageIds: stack, + groupingSource: "declared", + }; + return next; + }); + const primarySeries = seriesGroups[0]?.imageIds || stack; + setLoadingState("displayset"); + try { + setViewportModes(prev => { + const next = [...prev]; + next[viewportIdx] = 'stack'; + return next; + }); + setViewportImageIds(prev => { + const next = [...prev]; + next[viewportIdx] = primarySeries; + return next; + }); + setSelectedStackIdx(prev => { + const next = [...prev]; + next[viewportIdx] = stackIdx; + return next; + }); + setSeriesGroupsByViewport(prev => { + const next = [...prev]; + next[viewportIdx] = seriesGroups || []; + return next; + }); + setSelectedSeriesIdxByViewport(prev => { + const next = [...prev]; + next[viewportIdx] = 0; + return next; + }); + appLogger.debug("[handleLoadStack] autoCacheStack false: not fetching metadata for primarySeries"); + } finally { + setLoadingState(null); + } + return; + } + // Default: allow expansion/probe if autoCacheStack is true or grouping is not declared + const shouldExpandMultiframe = !(hasDeclaredGroups && (stackObj.imageIds || []).length > 1); + const stack = shouldExpandMultiframe + ? await expandMultiframeImageIds(stackObj.imageIds || []) + : dedupeImageIds((stackObj.imageIds || []).map(normalizeToWadouriOrExternal)); appLogger.debug("[handleLoadStack] Loading stack:", stack.length, "ids. First:", stack[0]?.slice(0, 80)); - - await setLoadedImageIdsAndCacheMeta(stack, { force: true }); - const seriesGroups = await deriveSeriesGroupsFromImageIds(stack); + await setLoadedImageIdsAndCacheMeta(stack, { force: Boolean(autoCacheStack) }); + const seriesGroups = hasDeclaredGroups + ? (stackObj.seriesGroups || []) + : await deriveSeriesGroupsFromImageIds(stack); appLogger.debug("[handleLoadStack] Derived series groups:", seriesGroups.length, "groups. First group has", seriesGroups[0]?.imageIds.length, "images"); setAvailableStacks(prev => { const next = [...prev]; if (!next[stackIdx]) return prev; - next[stackIdx] = { ...next[stackIdx], seriesGroups, imageIds: stack }; + next[stackIdx] = { + ...next[stackIdx], + seriesGroups, + imageIds: stack, + groupingSource: hasDeclaredGroups ? "declared" : "derived", + }; return next; }); - const primarySeries = seriesGroups[0]?.imageIds || stack; appLogger.debug("[handleLoadStack] Setting viewport to primarySeries with", primarySeries.length, "ids. First:", primarySeries[0]?.slice(0, 80)); @@ -1441,6 +1514,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const expandMultiframeImageIds = useCallback(async (rawImageIds: string[]) => { appLogger.debug("[expandMultiframeImageIds] Input:", rawImageIds.length, "imageIds"); + if (!autoCacheStack) { + const passthrough = dedupeImageIds((rawImageIds || []).map(normalizeToWadouriOrExternal)); + appLogger.debug("[expandMultiframeImageIds] autoCacheStack false: skipping multiframe probe/expansion"); + appLogger.debug("[expandMultiframeImageIds] Output:", passthrough.length, "total imageIds"); + return passthrough; + } const { singleFrameIds, multiframeGroups } = await splitMultiframeToStacks(rawImageIds); const result = dedupeImageIds([ ...singleFrameIds, @@ -1448,9 +1527,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat ]); appLogger.debug("[expandMultiframeImageIds] Output:", result.length, "total imageIds"); return result; - }, [splitMultiframeToStacks]); + }, [autoCacheStack, splitMultiframeToStacks]); const getFallbackBValuesForImageId = useCallback(async (imageId: string): Promise => { + if (!autoCacheStack) return []; if (!imageId.startsWith("wadouri:")) return []; const normalized = normalizeToWadouriOrExternal(imageId); @@ -1512,13 +1592,24 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat bValuesByBaseImageRef.current[baseUrl] = []; return []; } - }, []); + }, [autoCacheStack]); const deriveSeriesGroupsFromImageIds = useCallback(async (imageIds: string[]): Promise => { if (!Array.isArray(imageIds) || imageIds.length === 0) { return []; } + if (!autoCacheStack) { + // Strict lazy mode: do not probe metadata/tags client-side for grouping. + return [{ + key: "SERIES_0", + label: "Series 1", + imageIds: dedupeImageIds(imageIds), + bValue: null, + groupValues: {}, + }]; + } + const formatVec = (arr?: number[]) => { if (!Array.isArray(arr) || arr.length === 0) return ""; return arr.map((v) => { @@ -1725,6 +1816,42 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }) : []; + // When metadata is only partially available, avoid splitting by base keys + // (series UID/orientation) because missing tags can create phantom groups. + const candidatesWithSeriesUid = candidates.filter((c) => !!c.seriesInstanceUID).length; + const hasMissingSeriesUid = candidatesWithSeriesUid < candidates.length; + const distinctSeriesUidCount = new Set( + candidates + .map((c) => c.seriesInstanceUID) + .filter((value): value is string => !!value) + ).size; + const useSeriesUidInBaseKey = !hasMissingSeriesUid; + + const candidatesWithOrientation = candidates.filter((c) => c.orientationKey !== "ORI_UNKNOWN").length; + const hasUnknownOrientation = candidatesWithOrientation < candidates.length; + const distinctOrientationCount = new Set( + candidates + .map((c) => c.orientationKey) + .filter((value) => value && value !== "ORI_UNKNOWN") + ).size; + const useOrientationInBaseKey = !hasUnknownOrientation; + + if ( + (distinctSeriesUidCount > 1 && !useSeriesUidInBaseKey) + || (distinctOrientationCount > 1 && !useOrientationInBaseKey) + ) { + appLogger.debug( + "[deriveSeriesGroupsFromImageIds] partial metadata detected; suppressing base-key grouping", + { + candidates: candidates.length, + distinctSeriesUidCount, + distinctOrientationCount, + hasMissingSeriesUid, + hasUnknownOrientation, + } + ); + } + const groups = new Map { const baseKey = [ - candidate.seriesInstanceUID || "NO_UID", - candidate.orientationKey, + useSeriesUidInBaseKey ? (candidate.seriesInstanceUID || "NO_UID") : "SERIES_ANY", + useOrientationInBaseKey ? candidate.orientationKey : "ORI_ANY", ].join("|"); const advancedKey = activeRuleIds @@ -1775,6 +1902,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // If slices repeat uniformly (e.g. 56 images / 28 unique positions), // split by the occurrence index per slice position. if (activeRuleIds.length === 0 && candidates.length >= 4) { + const candidatesWithSliceKey = candidates.filter((c) => !!c.sliceKey).length; + const sliceKeyCoverage = candidatesWithSliceKey / candidates.length; + if (sliceKeyCoverage < 0.9) { + return [{ key: "SERIES_0", label: "Series 1", imageIds: dedupeImageIds(imageIds) }]; + } + const sliceCounts = new Map(); candidates.forEach((c) => { if (!c.sliceKey) return; @@ -1859,7 +1992,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }); return ordered; - }, [advancedGroupingEnabled, getFallbackBValuesForImageId, groupingRuleEnabled]); + }, [advancedGroupingEnabled, autoCacheStack, getFallbackBValuesForImageId, groupingRuleEnabled]); const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 }) @@ -2258,8 +2391,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const recomputeGroups = async () => { const recomputed = await Promise.all( availableStacks.map(async (stack) => { + if (stack.groupingSource === "declared" && (stack.seriesGroups || []).length > 0) { + return stack; + } const seriesGroups = await deriveSeriesGroupsFromImageIds(stack.imageIds); - return { ...stack, seriesGroups }; + return { ...stack, seriesGroups, groupingSource: "derived" as const }; }) ); @@ -3055,7 +3191,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat ) => { const imageIds = await expandMultiframeImageIds(rawImageIds.map(normalizeToWadouriOrExternal)); if (imageIds.length === 0) return; - await setLoadedImageIdsAndCacheMeta(imageIds, { force: true }); + await setLoadedImageIdsAndCacheMeta(imageIds, { force: Boolean(autoCacheStack) }); const seriesGroups = await deriveSeriesGroupsFromImageIds(imageIds); const primarySeries = seriesGroups[0]?.imageIds || imageIds; @@ -3073,7 +3209,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat return prev; } newStackIdx = prev.length; - return [...prev, { imageIds, name, seriesGroups }]; + return [...prev, { imageIds, name, seriesGroups, groupingSource: "derived" }]; }); const stackIdxToUse = existingStackIdx !== -1 ? existingStackIdx : newStackIdx; @@ -3184,17 +3320,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const stackObj = availableStacks[stackIdx]; if (!stackObj) return; let stackImageIds = stackObj.imageIds || []; + const hasDeclaredGroups = stackObj.groupingSource === "declared" && (stackObj.seriesGroups || []).length > 0; + const shouldExpandMultiframe = !(hasDeclaredGroups && stackImageIds.length > 1); // IMPORTANT: Expand multiframe images BEFORE caching metadata - const expandedImageIds = await expandMultiframeImageIds(stackImageIds); + const expandedImageIds = shouldExpandMultiframe + ? await expandMultiframeImageIds(stackImageIds) + : dedupeImageIds((stackImageIds || []).map(normalizeToWadouriOrExternal)); stackImageIds = expandedImageIds; - await setLoadedImageIdsAndCacheMeta(stackImageIds, { force: true }); - const seriesGroups = await deriveSeriesGroupsFromImageIds(stackImageIds); + await setLoadedImageIdsAndCacheMeta(stackImageIds, { force: Boolean(autoCacheStack) }); + const seriesGroups = hasDeclaredGroups + ? (stackObj.seriesGroups || []) + : await deriveSeriesGroupsFromImageIds(stackImageIds); setAvailableStacks(prev => { const next = [...prev]; if (!next[stackIdx]) return prev; - next[stackIdx] = { ...next[stackIdx], seriesGroups, imageIds: stackImageIds }; + next[stackIdx] = { + ...next[stackIdx], + seriesGroups, + imageIds: stackImageIds, + groupingSource: hasDeclaredGroups ? "declared" : "derived", + }; return next; }); const primarySeries = seriesGroups[0]?.imageIds || stackImageIds; @@ -4073,7 +4220,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat return next; }); - void setLoadedImageIdsAndCacheMeta(selectedGroup.imageIds, { force: true }); + void setLoadedImageIdsAndCacheMeta(selectedGroup.imageIds, { force: Boolean(autoCacheStack) }); window.setTimeout(() => { const vp = renderingEngineRef.current?.getViewport(`CT_${viewportIdx}`); if (!vp) return; @@ -4244,7 +4391,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => { if (!wadouriImageIds || wadouriImageIds.length === 0) return; const expandedImageIds = await expandMultiframeImageIds(wadouriImageIds); - await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true }); + await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: Boolean(autoCacheStack) }); const seriesGroups = await deriveSeriesGroupsFromImageIds(expandedImageIds); const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds; @@ -4252,7 +4399,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat let studyInstanceUID: string | undefined = undefined; try { const firstId = expandedImageIds[0]; - if (firstId && firstId.startsWith("wadouri:")) { + if (Boolean(autoCacheStack) && firstId && firstId.startsWith("wadouri:")) { const url = stripWadouriPrefix(firstId); const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); @@ -4264,7 +4411,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } setAvailableStacks(prev => { - const next = [...prev, { imageIds: expandedImageIds, studyInstanceUID, seriesGroups }]; + const next = [...prev, { imageIds: expandedImageIds, studyInstanceUID, seriesGroups, groupingSource: "derived" as const }]; setViewportImageIds(vpPrev => { const vpNext = [...vpPrev]; vpNext[0] = primarySeries; @@ -4530,15 +4677,59 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat window[`truncateStack_${apiKey}`] = truncateStack; window[`getCurrentStackPosition_${apiKey}`] = getCurrentStackPosition; - window[`loadAdditionalStack_${apiKey}`] = async (imageIds: string[], studyInstanceUID?: string) => { + window[`loadAdditionalStack_${apiKey}`] = async ( + imageIds: string[], + studyInstanceUID?: string, + declaredSeries?: Array<{ + key?: string; + label?: string; + imageIds?: string[]; + i?: string[]; + name?: string; + seriesInstanceUID?: string; + bValue?: number | null; + groupValues?: Partial>; + }>, + ) => { if (!Array.isArray(imageIds) || imageIds.length === 0) return; - const expandedImageIds = await expandMultiframeImageIds(imageIds); - await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true }); - const seriesGroups = await deriveSeriesGroupsFromImageIds(expandedImageIds); + const normalizedImageIds = dedupeImageIds((imageIds || []).map(normalizeToWadouriOrExternal)); + const hasDeclaredSeries = Array.isArray(declaredSeries) && declaredSeries.length > 0; + const expandedImageIds = hasDeclaredSeries + ? normalizedImageIds + : await expandMultiframeImageIds(normalizedImageIds); + await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: Boolean(autoCacheStack) }); + const serverSeriesGroups: StackSeriesGroup[] = Array.isArray(declaredSeries) + ? declaredSeries + .map((entry, idx) => { + const rawIds = Array.isArray(entry?.imageIds) + ? entry.imageIds + : (Array.isArray(entry?.i) ? entry.i : []); + const normalizedIds = dedupeImageIds(rawIds.map(normalizeToWadouriOrExternal)); + if (normalizedIds.length === 0) { + return null; + } + return { + key: String(entry?.key || entry?.seriesInstanceUID || `SERVER_SERIES_${idx}`), + label: + (typeof entry?.label === "string" && entry.label.trim()) + || (typeof entry?.name === "string" && entry.name.trim()) + || `Series ${idx + 1}`, + imageIds: normalizedIds, + seriesInstanceUID: entry?.seriesInstanceUID, + bValue: Number.isFinite(Number(entry?.bValue)) ? Number(entry?.bValue) : null, + groupValues: entry?.groupValues, + } as StackSeriesGroup; + }) + .filter((group): group is StackSeriesGroup => Boolean(group)) + : []; + + const seriesGroups = serverSeriesGroups.length > 0 + ? serverSeriesGroups + : await deriveSeriesGroupsFromImageIds(expandedImageIds); const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds; // Optionally extract StudyInstanceUID if not provided let uid = studyInstanceUID; - if (!uid && expandedImageIds[0]?.startsWith("wadouri:")) { + if (!uid && Boolean(autoCacheStack) && expandedImageIds[0]?.startsWith("wadouri:")) { try { const url = stripWadouriPrefix(expandedImageIds[0]); const response = await fetch(url); @@ -4549,7 +4740,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // Ignore errors, fallback to undefined } } - setAvailableStacks(prev => [...prev, { imageIds: expandedImageIds, studyInstanceUID: uid, seriesGroups }]); + setAvailableStacks(prev => [...prev, { + imageIds: expandedImageIds, + studyInstanceUID: uid, + seriesGroups, + groupingSource: serverSeriesGroups.length > 0 ? "declared" : "derived", + }]); // Optionally, auto-populate a viewport with the new stack if there is an empty viewport setViewportImageIds(prev => { const next = [...prev]; diff --git a/dicom-viewer/src/main.tsx b/dicom-viewer/src/main.tsx index 64d9038..8f3cab5 100644 --- a/dicom-viewer/src/main.tsx +++ b/dicom-viewer/src/main.tsx @@ -31,6 +31,17 @@ type ViewerDescriptor = { name?: string; caseId?: string; studyId?: string; + series?: Array<{ + key?: string; + label?: string; + imageIds?: string[]; + i?: string[]; + name?: string; + seriesId?: string | number; + seriesInstanceUID?: string; + bValue?: number | null; + groupValues?: Record; + }>; }; type RootRecord = { @@ -113,6 +124,7 @@ const parseToDescriptors = (parsed: any): Array => { name: s.name || s.label || undefined, caseId, studyId: s.studyId || studyId, + series: Array.isArray(s.series) ? s.series : undefined, }); }); return; @@ -130,6 +142,7 @@ const parseToDescriptors = (parsed: any): Array => { name: item.name || item.label || undefined, caseId: item.caseId || item.caseUID || undefined, studyId: item.studyId || item.studyUID || item.studyInstanceUID || undefined, + series: Array.isArray(item.series) ? item.series : undefined, }); return; }