many updates

This commit is contained in:
Ross
2026-05-21 22:44:13 +01:00
parent 6a0552c843
commit b527eb1139
2 changed files with 247 additions and 38 deletions
+234 -38
View File
@@ -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<number[]> => {
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<StackSeriesGroup[]> => {
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<string, {
imageIds: string[];
seriesInstanceUID?: string;
@@ -1736,8 +1863,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
candidates.forEach((candidate) => {
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<string, number>();
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<Record<GroupingRuleId, string>>;
}>,
) => {
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];
+13
View File
@@ -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<string, string>;
}>;
};
type RootRecord = {
@@ -113,6 +124,7 @@ const parseToDescriptors = (parsed: any): Array<ViewerDescriptor> => {
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<ViewerDescriptor> => {
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;
}