try to fix fluro

This commit is contained in:
Ross
2026-05-18 17:26:27 +01:00
parent 68248737ec
commit 7c17a27ec3
57 changed files with 388 additions and 54 deletions
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fluoro Multiframe Test</title>
<style>
html,body{height:100%;margin:0}
.dicom-viewer-root{width:100%;height:100vh;background:#222;color:#fff}
</style>
</head>
<body>
<div id="main_viewer" class="dicom-viewer-root" data-auto-cache-stack="false"></div>
<!-- Provide noop React Refresh preamble to avoid plugin-react runtime errors -->
<script>
window.$RefreshReg$ = window.$RefreshReg$ || function() {};
window.$RefreshSig$ = window.$RefreshSig$ || function() { return function(type) { return type; }; };
</script>
<script type="module" src="/src/main.tsx"></script>
<script>
// Viewer state for fluoro multiframe images (using relative paths from public/test_images/)
const state = {
"grid": {"rows": 1, "cols": 2},
"modes": ["stack", "stack"],
"stacks": [
{"i": ["wadouri:test_images/fluro/series_2369/1.3.12.2.1107.5.4.5.180506.30000026031914043350300000126.4.dcm"]},
{"i": ["wadouri:test_images/multibvalues/IMG_792_og2P5S6.dcm", "wadouri:test_images/multibvalues/IMG_793_3tKkg87.dcm"]}
],
"stackIdx": [0, 1],
"stackPos": [0, 1],
"props": [
{"voiRange": {"lower": 0, "upper": 500}, "invert": false, "interpolationType": 1, "VOILUTFunction": "LINEAR", "colormap": {"name": "Grayscale", "opacity": []}},
{"voiRange": {"lower": 0, "upper": 500}, "invert": false, "interpolationType": 1, "VOILUTFunction": "LINEAR", "colormap": {"name": "Grayscale", "opacity": []}}
],
"cam": [
{"viewUp": [0, 0, 1], "viewPlaneNormal": [1, 0, 0], "position": [150, 0, 0], "focalPoint": [0, 0, 0], "parallelProjection": true, "parallelScale": 100, "viewAngle": 90, "flipHorizontal": false, "flipVertical": false, "rotation": 0},
{"viewUp": [0, 0, 1], "viewPlaneNormal": [1, 0, 0], "position": [150, 0, 0], "focalPoint": [0, 0, 0], "parallelProjection": true, "parallelScale": 100, "viewAngle": 90, "flipHorizontal": false, "flipVertical": false, "rotation": 0}
],
"orientations": [null, null],
"volumeSliceIndices": [null, null]
};
function callWhenReady(fnName, payload) {
const maxWait = 5000;
const start = Date.now();
(function check() {
if (window[fnName] && typeof window[fnName] === 'function') {
try {
window[fnName](payload);
console.log('imported viewer state via', fnName);
} catch (e) {
console.error('importViewerState call failed', e);
}
return;
}
if (Date.now() - start > maxWait) {
console.warn('Timed out waiting for', fnName);
return;
}
setTimeout(check, 2000);
})();
}
document.addEventListener('DOMContentLoaded', () => {
callWhenReady('importViewerState', state);
});
</script>
</body>
</html>
+1
View File
@@ -23,6 +23,7 @@
<li><a href="/load-dir-test.html">load-dir-test.html</a> — directory loading test</li> <li><a href="/load-dir-test.html">load-dir-test.html</a> — directory loading test</li>
<li><a href="/non-dicom-test.html">non-dicom-test.html</a> — non-DICOM handling test</li> <li><a href="/non-dicom-test.html">non-dicom-test.html</a> — non-DICOM handling test</li>
<li><a href="/index.html">index.html</a> — project index</li> <li><a href="/index.html">index.html</a> — project index</li>
<li><a href="/fluoro-test.html">fluoro-test.html</a> — fluoro multiframe test</li>
</ul> </ul>
<p>If you want these links added to the README, tell me and I will update it.</p> <p>If you want these links added to the README, tell me and I will update it.</p>
+315 -54
View File
@@ -193,7 +193,23 @@ function withFrameQuery(imageId: string, frameNumber: number): string {
if (hasFrameQuery(imageId)) { if (hasFrameQuery(imageId)) {
return imageId.replace(/([?&])frame=\d+/i, `$1frame=${frame}`); return imageId.replace(/([?&])frame=\d+/i, `$1frame=${frame}`);
} }
return `${imageId}${imageId.includes("?") ? "&" : "?"}frame=${frame}`; // Cornerstone's wadouri multiframe path expects '&frame=' specifically.
return `${imageId}&frame=${frame}`;
}
function getFrameNumberFromImageId(imageId: string): number | null {
const match = imageId.match(/[?&]frame=(\d+)/i);
if (!match) return null;
const parsed = Number(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
}
function parseNumericValueList(raw?: string | null): number[] {
if (!raw) return [];
return raw
.split(/\\|,/) // DICOM VM separator is '\\'; logs often show comma-separated values.
.map((part) => Number(part.trim()))
.filter((value) => Number.isFinite(value));
} }
function normalizeToWadouriOrExternal(imageId: string): string { function normalizeToWadouriOrExternal(imageId: string): string {
@@ -376,7 +392,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const [cineFpsByViewport, setCineFpsByViewport] = useState<number[]>( const [cineFpsByViewport, setCineFpsByViewport] = useState<number[]>(
() => Array(MAX_VIEWPORTS).fill(12) () => Array(MAX_VIEWPORTS).fill(12)
); );
const [cineControlsOpenByViewport, setCineControlsOpenByViewport] = useState<boolean[]>(
() => Array(MAX_VIEWPORTS).fill(false)
);
const multiframeCountCacheRef = useRef<Record<string, number>>({}); const multiframeCountCacheRef = useRef<Record<string, number>>({});
const bValuesByBaseImageRef = useRef<Record<string, number[]>>({});
// Client-side generated thumbnails cache: stackIndex -> dataURL // Client-side generated thumbnails cache: stackIndex -> dataURL
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({}); const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
@@ -757,7 +777,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
stacksRaw.map(async (d) => { stacksRaw.map(async (d) => {
if (Array.isArray(d)) { if (Array.isArray(d)) {
const expanded = await expandMultiframeImageIds(d as string[]); const expanded = await expandMultiframeImageIds(d as string[]);
return { imageIds: expanded }; const seriesGroups = await deriveSeriesGroupsFromImageIds(expanded);
return { imageIds: expanded, seriesGroups };
} }
const imageIdsRaw = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []); const imageIdsRaw = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []);
const imageIds = await expandMultiframeImageIds(imageIdsRaw); const imageIds = await expandMultiframeImageIds(imageIdsRaw);
@@ -788,6 +809,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
} }
if (!seriesGroups || seriesGroups.length === 0) {
seriesGroups = await deriveSeriesGroupsFromImageIds(imageIds);
}
// try to preserve old studyInstanceUID extraction if needed (best-effort) // try to preserve old studyInstanceUID extraction if needed (best-effort)
let studyInstanceUID: string | undefined = (d as any).studyInstanceUID; let studyInstanceUID: string | undefined = (d as any).studyInstanceUID;
// optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available // optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available
@@ -821,10 +846,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
// Seed viewports with first stack if present (preserve old behavior) // Seed viewports with first stack if present (preserve old behavior)
if (normalized[0]) { if (normalized[0]) {
const firstStack = normalized[0]; const firstStack = normalized[0];
const firstSeries = firstStack.seriesGroups?.[0]?.imageIds || firstStack.imageIds; let firstGroups = firstStack.seriesGroups || [];
if (firstGroups.length === 0) {
await setLoadedImageIdsAndCacheMeta(firstStack.imageIds, { force: true });
firstGroups = await deriveSeriesGroupsFromImageIds(firstStack.imageIds);
normalized[0] = { ...firstStack, seriesGroups: firstGroups };
setAvailableStacks([...normalized]);
}
const firstSeries = firstGroups[0]?.imageIds || firstStack.imageIds;
await setLoadedImageIdsAndCacheMeta(firstSeries); await setLoadedImageIdsAndCacheMeta(firstSeries);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(firstSeries || [])); setViewportImageIds(Array(MAX_VIEWPORTS).fill(firstSeries || []));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstStack.seriesGroups || [])); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstGroups));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
} else { } else {
setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
@@ -844,21 +876,26 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
const stackObj = availableStacks[stackIdx]; const stackObj = availableStacks[stackIdx];
if (!stackObj) return; if (!stackObj) return;
let seriesGroups = stackObj.seriesGroups; let stack = stackObj.imageIds;
const stack = stackObj.imageIds; console.log("[handleLoadStack] Starting with imageIds:", stack.length, "ids. First:", stack[0]?.slice(0, 80));
// IMPORTANT: Expand multiframe images BEFORE caching metadata
const expandedStack = await expandMultiframeImageIds(stack);
console.log("[handleLoadStack] After expansion:", expandedStack.length, "ids. First:", expandedStack[0]?.slice(0, 80));
stack = expandedStack;
await setLoadedImageIdsAndCacheMeta(stack, { force: true }); await setLoadedImageIdsAndCacheMeta(stack, { force: true });
if (!seriesGroups || seriesGroups.length === 0) { const seriesGroups = await deriveSeriesGroupsFromImageIds(stack);
seriesGroups = deriveSeriesGroupsFromImageIds(stack); console.log("[handleLoadStack] Derived series groups:", seriesGroups.length, "groups. First group has", seriesGroups[0]?.imageIds.length, "images");
setAvailableStacks(prev => { setAvailableStacks(prev => {
const next = [...prev]; const next = [...prev];
if (!next[stackIdx]) return prev; if (!next[stackIdx]) return prev;
next[stackIdx] = { ...next[stackIdx], seriesGroups }; next[stackIdx] = { ...next[stackIdx], seriesGroups, imageIds: stack };
return next; return next;
}); });
}
const primarySeries = seriesGroups[0]?.imageIds || stack; const primarySeries = seriesGroups[0]?.imageIds || stack;
console.log("[handleLoadStack] Setting viewport to primarySeries with", primarySeries.length, "ids. First:", primarySeries[0]?.slice(0, 80));
setLoadingState("displayset"); setLoadingState("displayset");
try { try {
@@ -1129,18 +1166,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const shouldCache = Boolean(autoCacheStack || options?.force); const shouldCache = Boolean(autoCacheStack || options?.force);
if (shouldCache) { if (shouldCache) {
const urlsToLoad = new Set<string>();
for (const imageId of imageIds) { for (const imageId of imageIds) {
if (imageId.startsWith("wadouri:")) { if (imageId.startsWith("wadouri:")) {
try { try {
const url = stripWadouriPrefix(imageId); const baseUrl = stripWadouriPrefix(imageId).replace(/[?&]frame=\d+/gi, "");
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { if (baseUrl) {
// @ts-ignore urlsToLoad.add(baseUrl);
const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url);
if (result && typeof result.then === "function") {
loadPromises.push(result);
}
} else {
console.warn("MetaDataManager not found. Unable to cache metadata.");
} }
} catch (e) { } catch (e) {
console.warn(`Failed to cache metadata for ${imageId}:`, e); console.warn(`Failed to cache metadata for ${imageId}:`, e);
@@ -1148,6 +1180,18 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
} }
for (const url of urlsToLoad) {
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
// @ts-ignore
const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url);
if (result && typeof result.then === "function") {
loadPromises.push(result);
}
} else {
console.warn("MetaDataManager not found. Unable to cache metadata.");
}
}
// Wait for all metadata loads to finish // Wait for all metadata loads to finish
await Promise.all(loadPromises); await Promise.all(loadPromises);
@@ -1169,6 +1213,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const cacheKey = `wadouri:${url}`; const cacheKey = `wadouri:${url}`;
const cached = multiframeCountCacheRef.current[cacheKey]; const cached = multiframeCountCacheRef.current[cacheKey];
if (cached !== undefined) { if (cached !== undefined) {
console.log("[getNumberOfFramesForImageId] Cache hit:", cacheKey.slice(0, 60), "->", cached);
return cached; return cached;
} }
@@ -1182,6 +1227,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const multiframeMeta = metaData.get("multiframeModule", normalized) as any; const multiframeMeta = metaData.get("multiframeModule", normalized) as any;
const fromMeta = Number(multiframeMeta?.numberOfFrames); const fromMeta = Number(multiframeMeta?.numberOfFrames);
if (Number.isFinite(fromMeta) && fromMeta > 1) { if (Number.isFinite(fromMeta) && fromMeta > 1) {
console.log("[getNumberOfFramesForImageId] Found in metadata:", fromMeta);
multiframeCountCacheRef.current[cacheKey] = Math.floor(fromMeta); multiframeCountCacheRef.current[cacheKey] = Math.floor(fromMeta);
return Math.floor(fromMeta); return Math.floor(fromMeta);
} }
@@ -1194,6 +1240,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
if (cacheManager?.get) { if (cacheManager?.get) {
const dataSet = cacheManager.get(url); const dataSet = cacheManager.get(url);
const fromCache = readFramesFromDataSet(dataSet); const fromCache = readFramesFromDataSet(dataSet);
console.log("[getNumberOfFramesForImageId] Found in cache manager:", fromCache);
multiframeCountCacheRef.current[cacheKey] = fromCache; multiframeCountCacheRef.current[cacheKey] = fromCache;
return fromCache; return fromCache;
} }
@@ -1202,13 +1249,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
try { try {
console.log("[getNumberOfFramesForImageId] Fetching:", url.slice(0, 80));
const response = await fetch(url); const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
const fromFetch = readFramesFromDataSet(dataSet); const fromFetch = readFramesFromDataSet(dataSet);
console.log("[getNumberOfFramesForImageId] Found via fetch:", fromFetch);
multiframeCountCacheRef.current[cacheKey] = fromFetch; multiframeCountCacheRef.current[cacheKey] = fromFetch;
return fromFetch; return fromFetch;
} catch (e) { } catch (e) {
console.log("[getNumberOfFramesForImageId] Fetch failed, returning 1:", e);
multiframeCountCacheRef.current[cacheKey] = 1; multiframeCountCacheRef.current[cacheKey] = 1;
return 1; return 1;
} }
@@ -1216,6 +1266,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const expandMultiframeImageIds = useCallback(async (rawImageIds: string[]) => { const expandMultiframeImageIds = useCallback(async (rawImageIds: string[]) => {
const expanded: string[] = []; const expanded: string[] = [];
console.log("[expandMultiframeImageIds] Input:", rawImageIds.length, "imageIds");
for (const raw of rawImageIds || []) { for (const raw of rawImageIds || []) {
const imageId = normalizeToWadouriOrExternal(raw); const imageId = normalizeToWadouriOrExternal(raw);
@@ -1225,6 +1276,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
const frameCount = await getNumberOfFramesForImageId(imageId); const frameCount = await getNumberOfFramesForImageId(imageId);
console.log("[expandMultiframeImageIds] frameCount for", imageId.slice(0, 80), ":", frameCount);
if (frameCount <= 1) { if (frameCount <= 1) {
expanded.push(imageId); expanded.push(imageId);
continue; continue;
@@ -1233,16 +1285,87 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
for (let frame = 1; frame <= frameCount; frame++) { for (let frame = 1; frame <= frameCount; frame++) {
expanded.push(withFrameQuery(imageId, frame)); expanded.push(withFrameQuery(imageId, frame));
} }
console.log("[expandMultiframeImageIds] Expanded to", frameCount, "frames");
} }
return dedupeImageIds(expanded); const result = dedupeImageIds(expanded);
console.log("[expandMultiframeImageIds] Output:", result.length, "total imageIds");
return result;
}, [getNumberOfFramesForImageId]); }, [getNumberOfFramesForImageId]);
const deriveSeriesGroupsFromImageIds = useCallback((imageIds: string[]): StackSeriesGroup[] => { const getFallbackBValuesForImageId = useCallback(async (imageId: string): Promise<number[]> => {
if (!imageId.startsWith("wadouri:")) return [];
const normalized = normalizeToWadouriOrExternal(imageId);
const baseUrl = stripWadouriPrefix(normalized).replace(/[?&]frame=\d+/i, "");
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 privatePhilips = parseNumericValueList(dataSet.string("x20011003"));
if (privatePhilips.length > 0) return privatePhilips;
return [];
};
try {
const cacheManager = cornerstoneDICOMImageLoader?.wadouri?.dataSetCacheManager;
if (cacheManager?.get) {
const existing = cacheManager.get(baseUrl);
const fromExisting = parseBValuesFromDataSet(existing);
if (fromExisting.length > 0) {
bValuesByBaseImageRef.current[baseUrl] = fromExisting;
return fromExisting;
}
}
} catch (e) {
// Continue with fetch fallback.
}
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;
} catch (e) {
bValuesByBaseImageRef.current[baseUrl] = [];
return [];
}
}, []);
const deriveSeriesGroupsFromImageIds = useCallback(async (imageIds: string[]): Promise<StackSeriesGroup[]> => {
if (!Array.isArray(imageIds) || imageIds.length === 0) { if (!Array.isArray(imageIds) || imageIds.length === 0) {
return []; return [];
} }
const frameOrderByBase = new Map<string, string[]>();
imageIds.forEach((id) => {
const base = stripWadouriPrefix(normalizeToWadouriOrExternal(id)).replace(/[?&]frame=\d+/i, "");
const arr = frameOrderByBase.get(base) || [];
arr.push(id);
frameOrderByBase.set(base, arr);
});
frameOrderByBase.forEach((arr, base) => {
arr.sort((a, b) => {
const fa = getFrameNumberFromImageId(a) || 1;
const fb = getFrameNumberFromImageId(b) || 1;
return fa - fb;
});
frameOrderByBase.set(base, arr);
});
const fallbackByBase = new Map<string, number[]>();
await Promise.all(Array.from(frameOrderByBase.keys()).map(async (base) => {
const firstId = `wadouri:${base}`;
const values = await getFallbackBValuesForImageId(firstId);
fallbackByBase.set(base, values);
}));
const groups = new Map<string, { imageIds: string[]; seriesInstanceUID?: string; seriesDescription?: string; seriesNumber?: number; bValue?: number | null }>(); const groups = new Map<string, { imageIds: string[]; seriesInstanceUID?: string; seriesDescription?: string; seriesNumber?: number; bValue?: number | null }>();
imageIds.forEach((id) => { imageIds.forEach((id) => {
@@ -1255,8 +1378,33 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const seriesNumber = Number(seriesMeta.seriesNumber); const seriesNumber = Number(seriesMeta.seriesNumber);
const temporalPosition = Number(imageMeta.temporalPositionIdentifier); const temporalPosition = Number(imageMeta.temporalPositionIdentifier);
const triggerTime = Number(imageMeta.triggerTime); const triggerTime = Number(imageMeta.triggerTime);
const bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue);
const bValue = Number.isFinite(bValueRaw) ? bValueRaw : null; let bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue);
let bValue = Number.isFinite(bValueRaw) ? bValueRaw : null;
if (bValue === null) {
const base = stripWadouriPrefix(normalizeToWadouriOrExternal(id)).replace(/[?&]frame=\d+/i, "");
const fallbackValues = fallbackByBase.get(base) || [];
if (fallbackValues.length > 0) {
const orderedIds = frameOrderByBase.get(base) || [id];
const framePos = Math.max(0, orderedIds.indexOf(id));
const totalForBase = Math.max(1, orderedIds.length);
if (fallbackValues.length === 1) {
bValue = fallbackValues[0];
} else if (fallbackValues.length === totalForBase) {
bValue = fallbackValues[Math.min(fallbackValues.length - 1, framePos)];
} else if (fallbackValues.length === 2 && totalForBase % 2 === 0) {
bValue = framePos < totalForBase / 2 ? fallbackValues[0] : fallbackValues[1];
} else {
const bucket = Math.min(
fallbackValues.length - 1,
Math.floor((framePos / totalForBase) * fallbackValues.length)
);
bValue = fallbackValues[bucket];
}
}
}
const temporalKey = Number.isFinite(temporalPosition) ? `t${temporalPosition}` : ""; const temporalKey = Number.isFinite(temporalPosition) ? `t${temporalPosition}` : "";
const triggerKey = Number.isFinite(triggerTime) ? `tr${Math.round(triggerTime)}` : ""; const triggerKey = Number.isFinite(triggerTime) ? `tr${Math.round(triggerTime)}` : "";
@@ -1298,7 +1446,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}); });
return ordered; return ordered;
}, []); }, [getFallbackBValuesForImageId]);
const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 }) const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 })
@@ -1364,6 +1512,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
resolution: string, resolution: string,
spacing: string, spacing: string,
focalPoint: string, focalPoint: string,
frameIndex?: number,
totalFrames?: number,
}>>(() => }>>(() =>
Array(viewportGrid.rows * viewportGrid.cols).fill({ Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0, index: 0,
@@ -1961,17 +2111,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
return frames; return frames;
}); });
const seriesGroups = await deriveSeriesGroupsFromImageIds(imageIds);
stacks.push({ stacks.push({
imageIds, imageIds,
studyInstanceUID: g.studyInstanceUID, studyInstanceUID: g.studyInstanceUID,
seriesInstanceUID: g.seriesInstanceUID, seriesInstanceUID: g.seriesInstanceUID,
seriesGroups: [{ seriesGroups,
key: String(g.seriesInstanceUID || key),
label: `Series ${stacks.length + 1}`,
imageIds,
seriesInstanceUID: g.seriesInstanceUID,
bValue: null,
}],
}); });
} }
@@ -2086,6 +2231,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
let resolution = ""; let resolution = "";
let spacing = ""; let spacing = "";
let focalPoint = ""; let focalPoint = "";
let frameIndex = undefined;
let totalFrames = undefined;
if (viewportModes[viewportIdx] === "stack") { if (viewportModes[viewportIdx] === "stack") {
currentIndex = typeof viewport.getCurrentImageIdIndex === "function" currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
@@ -2098,6 +2245,35 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const currentImageId = imageIds[currentIndex]; const currentImageId = imageIds[currentIndex];
if (currentImageId) { if (currentImageId) {
console.log("[updateOverlay] Current image:", currentImageId.slice(0, 80), "frameIndex status:", frameIndex, "totalFrames:", totalFrames);
// Detect frame index from imageId query
if (currentImageId.includes('&frame=')) {
const match = currentImageId.match(/&frame=(\d+)/);
if (match) {
frameIndex = parseInt(match[1], 10);
console.log("[updateOverlay] Frame detected:", frameIndex);
// Try to get total frames from metadata
const baseImageId = currentImageId.split('&frame=')[0];
const multiframeMeta = metaData.get("multiframeModule", baseImageId) as any;
if (multiframeMeta?.numberOfFrames) {
totalFrames = multiframeMeta.numberOfFrames;
console.log("[updateOverlay] Total frames from metadata:", totalFrames);
} else {
// Fallback: count unique frame indices in imageIds
const frameNumbers = imageIds.map(id => {
const m = id.match(/&frame=(\d+)/);
return m ? parseInt(m[1], 10) : null;
}).filter(n => n !== null && typeof n === 'number');
if (frameNumbers.length > 0) {
totalFrames = Math.max(...frameNumbers as number[]);
console.log("[updateOverlay] Total frames from imageIds:", totalFrames);
}
}
}
} else {
console.log("[updateOverlay] No frame query in imageId. imageIds.length:", imageIds.length);
}
const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any; const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any;
const planeMeta = metaData.get("imagePlaneModule", currentImageId) as any; const planeMeta = metaData.get("imagePlaneModule", currentImageId) as any;
const pixelMeta = metaData.get("imagePixelModule", currentImageId) as any; const pixelMeta = metaData.get("imagePixelModule", currentImageId) as any;
@@ -2222,6 +2398,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
resolution, resolution,
spacing, spacing,
focalPoint, focalPoint,
frameIndex,
totalFrames,
}; };
return next; return next;
}); });
@@ -2278,7 +2456,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const imageIds = await expandMultiframeImageIds(rawImageIds.map(normalizeToWadouriOrExternal)); const imageIds = await expandMultiframeImageIds(rawImageIds.map(normalizeToWadouriOrExternal));
if (imageIds.length === 0) return; if (imageIds.length === 0) return;
await setLoadedImageIdsAndCacheMeta(imageIds, { force: true }); await setLoadedImageIdsAndCacheMeta(imageIds, { force: true });
const seriesGroups = deriveSeriesGroupsFromImageIds(imageIds); const seriesGroups = await deriveSeriesGroupsFromImageIds(imageIds);
const primarySeries = seriesGroups[0]?.imageIds || imageIds; const primarySeries = seriesGroups[0]?.imageIds || imageIds;
// Add the incoming stack to the panel and capture its index. // Add the incoming stack to the panel and capture its index.
@@ -2393,19 +2571,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => { const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
const stackObj = availableStacks[stackIdx]; const stackObj = availableStacks[stackIdx];
if (!stackObj) return; if (!stackObj) return;
let seriesGroups = stackObj.seriesGroups; let stackImageIds = stackObj.imageIds || [];
const stackImageIds = stackObj.imageIds || [];
// IMPORTANT: Expand multiframe images BEFORE caching metadata
const expandedImageIds = await expandMultiframeImageIds(stackImageIds);
stackImageIds = expandedImageIds;
await setLoadedImageIdsAndCacheMeta(stackImageIds, { force: true }); await setLoadedImageIdsAndCacheMeta(stackImageIds, { force: true });
if (!seriesGroups || seriesGroups.length === 0) { const seriesGroups = await deriveSeriesGroupsFromImageIds(stackImageIds);
seriesGroups = deriveSeriesGroupsFromImageIds(stackImageIds); setAvailableStacks(prev => {
setAvailableStacks(prev => { const next = [...prev];
const next = [...prev]; if (!next[stackIdx]) return prev;
if (!next[stackIdx]) return prev; next[stackIdx] = { ...next[stackIdx], seriesGroups, imageIds: stackImageIds };
next[stackIdx] = { ...next[stackIdx], seriesGroups }; return next;
return next; });
});
}
const primarySeries = seriesGroups[0]?.imageIds || stackImageIds; const primarySeries = seriesGroups[0]?.imageIds || stackImageIds;
const resolvedZone = resolveDropZone(requestedZone); const resolvedZone = resolveDropZone(requestedZone);
@@ -3238,6 +3417,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
focalPoint: "", focalPoint: "",
})); }));
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false)); setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
setCineControlsOpenByViewport(Array(MAX_VIEWPORTS).fill(false));
}, [viewportGrid]); }, [viewportGrid]);
// Re-apply bindings when tool sets or ctrl changes // Re-apply bindings when tool sets or ctrl changes
@@ -3324,7 +3504,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
if (!wadouriImageIds || wadouriImageIds.length === 0) return; if (!wadouriImageIds || wadouriImageIds.length === 0) return;
const expandedImageIds = await expandMultiframeImageIds(wadouriImageIds); const expandedImageIds = await expandMultiframeImageIds(wadouriImageIds);
await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true }); await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true });
const seriesGroups = deriveSeriesGroupsFromImageIds(expandedImageIds); const seriesGroups = await deriveSeriesGroupsFromImageIds(expandedImageIds);
const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds; const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds;
// Try to extract StudyInstanceUID from the first image if possible // Try to extract StudyInstanceUID from the first image if possible
@@ -3496,6 +3676,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => [])); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
setCinePlayingByViewport(Array(MAX_VIEWPORTS).fill(false)); setCinePlayingByViewport(Array(MAX_VIEWPORTS).fill(false));
setCineControlsOpenByViewport(Array(MAX_VIEWPORTS).fill(false));
setViewportModes(Array(MAX_VIEWPORTS).fill('stack')); setViewportModes(Array(MAX_VIEWPORTS).fill('stack'));
Object.values(stackThumbnailsRef.current).forEach((thumbnail) => { Object.values(stackThumbnailsRef.current).forEach((thumbnail) => {
if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) { if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) {
@@ -3612,7 +3793,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
if (!Array.isArray(imageIds) || imageIds.length === 0) return; if (!Array.isArray(imageIds) || imageIds.length === 0) return;
const expandedImageIds = await expandMultiframeImageIds(imageIds); const expandedImageIds = await expandMultiframeImageIds(imageIds);
await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true }); await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true });
const seriesGroups = deriveSeriesGroupsFromImageIds(expandedImageIds); const seriesGroups = await deriveSeriesGroupsFromImageIds(expandedImageIds);
const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds; const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds;
// Optionally extract StudyInstanceUID if not provided // Optionally extract StudyInstanceUID if not provided
let uid = studyInstanceUID; let uid = studyInstanceUID;
@@ -4755,6 +4936,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
<> <>
<div> <div>
Image: {imageInfos[i]?.index} / {imageInfos[i]?.total} Image: {imageInfos[i]?.index} / {imageInfos[i]?.total}
{imageInfos[i]?.frameIndex && imageInfos[i]?.totalFrames ? (
<> &nbsp; [Frame: {imageInfos[i]?.frameIndex} / {imageInfos[i]?.totalFrames}]</>
) : null}
</div> </div>
<div> <div>
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth} WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
@@ -4812,8 +4996,43 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
bottom: 8, bottom: 8,
zIndex: 110, // above the bottom study selector (zIndex:20) zIndex: 110, // above the bottom study selector (zIndex:20)
pointerEvents: "auto", // <-- ensure pointer events are enabled pointerEvents: "auto", // <-- ensure pointer events are enabled
display: "flex",
alignItems: "flex-end",
gap: 6,
}} }}
> >
<button
style={{
background: cineControlsOpenByViewport[i] ? "rgba(30,120,200,0.95)" : "rgba(0,0,0,0.7)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: "4px",
fontSize: "12px",
fontWeight: 700,
padding: "6px 9px",
cursor: "pointer",
minHeight: 30,
}}
title={cineControlsOpenByViewport[i] ? "Hide cine controls" : "Show cine controls"}
onClick={(e) => {
e.stopPropagation();
setCineControlsOpenByViewport((prev) => {
const next = [...prev];
const nextOpen = !next[i];
next[i] = nextOpen;
if (!nextOpen) {
setCinePlayingByViewport((playing) => {
const p = [...playing];
p[i] = false;
return p;
});
}
return next;
});
}}
>
Cine
</button>
<div <div
className="preset-menu" className="preset-menu"
style={{ style={{
@@ -4913,7 +5132,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
)} )}
</div> </div>
</div> </div>
{viewportModes[i] === "stack" && ( {viewportModes[i] === "stack" && (cineControlsOpenByViewport[i] || (seriesGroupsByViewport[i] || []).length > 1 || (imageInfos[i]?.frameIndex && imageInfos[i]?.totalFrames)) && (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -4934,7 +5153,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{imageInfos[i]?.total > 1 && ( {cineControlsOpenByViewport[i] && imageInfos[i]?.total > 1 && (
<> <>
<button <button
style={{ style={{
@@ -4978,6 +5197,43 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
</label> </label>
</> </>
)} )}
{imageInfos[i]?.frameIndex && imageInfos[i]?.totalFrames && imageInfos[i]?.totalFrames > 1 && (
<>
<span style={{ color: "#fff", fontSize: 12, whiteSpace: "nowrap" }}>Frame</span>
<input
type="range"
min={1}
max={imageInfos[i]?.totalFrames || 1}
step={1}
value={Math.max(1, Math.min(imageInfos[i]?.totalFrames || 1, imageInfos[i]?.frameIndex || 1))}
onChange={(e) => {
const targetFrame = Number(e.target.value);
const imageIds = viewportImageIds[i] || [];
const targetIdx = imageIds.findIndex(id => {
const m = id.match(/&frame=(\d+)/);
return m && parseInt(m[1], 10) === targetFrame;
});
if (targetIdx >= 0) {
const viewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
if (viewport && typeof csUtilities.jumpToSlice === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: targetIdx });
}
}
}}
style={{ width: 100 }}
/>
<span
style={{
color: "#e0e0e0",
fontSize: 11,
minWidth: 50,
textAlign: "right",
}}
>
{imageInfos[i]?.frameIndex} / {imageInfos[i]?.totalFrames}
</span>
</>
)}
{(seriesGroupsByViewport[i] || []).length > 1 && ( {(seriesGroupsByViewport[i] || []).length > 1 && (
<> <>
<span style={{ color: "#fff", fontSize: 12, whiteSpace: "nowrap" }}>Series</span> <span style={{ color: "#fff", fontSize: 12, whiteSpace: "nowrap" }}>Series</span>
@@ -5239,7 +5495,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
position: "absolute", position: "absolute",
left: 0, left: 0,
right: 0, right: 0,
bottom: viewportModes[i] === "stack" ? 56 : 8, bottom: viewportModes[i] === "stack" && (cineControlsOpenByViewport[i] || (seriesGroupsByViewport[i] || []).length > 1) ? 56 : 8,
zIndex: 20, zIndex: 20,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -5340,8 +5596,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
}} }}
onBlur={e => { onBlur={e => {
const blurHost = e.currentTarget as HTMLElement | null;
setTimeout(() => { setTimeout(() => {
if (!(e.currentTarget as HTMLElement).contains(document.activeElement)) { if (!blurHost) {
setOpenDropdownIdx(null);
return;
}
if (!blurHost.contains(document.activeElement)) {
setOpenDropdownIdx(null); setOpenDropdownIdx(null);
} }
}, 0); }, 0);