Add thumbnail generation for image stacks and improve rendering logic
This commit is contained in:
@@ -30,7 +30,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Example A: array of named stacks (simple) -->
|
<!--
|
||||||
<div
|
<div
|
||||||
class="dicom-viewer-root"
|
class="dicom-viewer-root"
|
||||||
id="viewer_named_simple"
|
id="viewer_named_simple"
|
||||||
@@ -54,7 +54,6 @@
|
|||||||
style="width:600px;height:400px;"
|
style="width:600px;height:400px;"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Example B: nested cases with multiple stacks per case -->
|
|
||||||
<div
|
<div
|
||||||
class="dicom-viewer-root"
|
class="dicom-viewer-root"
|
||||||
id="viewer_named_cases"
|
id="viewer_named_cases"
|
||||||
@@ -93,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
]'
|
]'
|
||||||
style="width:600px;height:400px;margin-top:12px;"
|
style="width:600px;height:400px;margin-top:12px;"
|
||||||
></div>
|
></div> -->
|
||||||
<!-- <div id="root2" class="dicom-viewer-root" style="max-height:500px; height:500px; overflow:auto;"
|
<!-- <div id="root2" class="dicom-viewer-root" style="max-height:500px; height:500px; overflow:auto;"
|
||||||
data-images="["http://localhost:8000/media/atlas/dicom/4596ea7654b7a5074d2c3f40039c556b11fb01ae.dcm", "http://localhost:8000/media/atlas/dicom/b2fa301bc731d15be3e000402c9a79c114d17aa5.dcm", "http://localhost:8000/media/atlas/dicom/66310ef801fbecb37c5ced160c450dfd5ecc6d2d.dcm", "http://localhost:8000/media/atlas/dicom/63d2d117bf256a76a61190066f91eda6c4d67c8f.dcm", "http://localhost:8000/media/atlas/dicom/181a3575ea7fb8bf8f6449ff61e6e369a3247454.dcm", "http://localhost:8000/media/atlas/dicom/7c5c0538339266ac8897fa21c481b2f61172068e.dcm", "http://localhost:8000/media/atlas/dicom/1681410681308af7111af6644fed598fde304b96.dcm", "http://localhost:8000/media/atlas/dicom/4adcd3d7a64a9c59b530f730ce0d991bf7f27813.dcm", "http://localhost:8000/media/atlas/dicom/671e6004c7fb402a627a2282bbf304d7b4dcc12b.dcm", "http://localhost:8000/media/atlas/dicom/8d18ac56914b9de72b8aecced09654ec4b2c9bb7.dcm", "http://localhost:8000/media/atlas/dicom/74ce708a90dff0636fc4bc914fe47e8b6d9320bd.dcm", "http://localhost:8000/media/atlas/dicom/a96b6c9900570e076edf849a0ae3b52734ed2650.dcm", "http://localhost:8000/media/atlas/dicom/0183718d9336458478ec42130395d78e01492bd6.dcm", "http://localhost:8000/media/atlas/dicom/52eee968bbce5590274346eed43f1837f9daba11.dcm", "http://localhost:8000/media/atlas/dicom/961449f88197566c4643fcfd20f48ea4e810490b.dcm", "http://localhost:8000/media/atlas/dicom/1a16ad5b25e11739eadbcb8ea461072b100113a0.dcm", "http://localhost:8000/media/atlas/dicom/b89ddcc3f499059d2dedb0028a2bb1ce4abf5574.dcm", "http://localhost:8000/media/atlas/dicom/c1379c03a4e449c2fa88784d601cb766af907739.dcm", "http://localhost:8000/media/atlas/dicom/4fb532d5003c59a73f7e4248f365b1a8660ea710.dcm", "http://localhost:8000/media/atlas/dicom/e861b0de042a1f213cbccc995679aacd29c7ef53.dcm", "http://localhost:8000/media/atlas/dicom/1a854282afe264c3afff6a6e287cd646b2193a0c.dcm", "http://localhost:8000/media/atlas/dicom/f59d6d9bc33160abad3af8e2b246179b3574d884.dcm", "http://localhost:8000/media/atlas/dicom/04fadab0ed8b3f1925e601c4f9cfb792c95626c2.dcm", "http://localhost:8000/media/atlas/dicom/d45d0b12fb650e65daeeee4e5fa3b21c988597f6.dcm", "http://localhost:8000/media/atlas/dicom/e19c47b7787ac45dbc225319dfa143dc2c1fdf1f.dcm", "http://localhost:8000/media/atlas/dicom/dccabf3103a5be254a45457ce2d8d9ce9cb19a6a.dcm"]"
|
data-images="["http://localhost:8000/media/atlas/dicom/4596ea7654b7a5074d2c3f40039c556b11fb01ae.dcm", "http://localhost:8000/media/atlas/dicom/b2fa301bc731d15be3e000402c9a79c114d17aa5.dcm", "http://localhost:8000/media/atlas/dicom/66310ef801fbecb37c5ced160c450dfd5ecc6d2d.dcm", "http://localhost:8000/media/atlas/dicom/63d2d117bf256a76a61190066f91eda6c4d67c8f.dcm", "http://localhost:8000/media/atlas/dicom/181a3575ea7fb8bf8f6449ff61e6e369a3247454.dcm", "http://localhost:8000/media/atlas/dicom/7c5c0538339266ac8897fa21c481b2f61172068e.dcm", "http://localhost:8000/media/atlas/dicom/1681410681308af7111af6644fed598fde304b96.dcm", "http://localhost:8000/media/atlas/dicom/4adcd3d7a64a9c59b530f730ce0d991bf7f27813.dcm", "http://localhost:8000/media/atlas/dicom/671e6004c7fb402a627a2282bbf304d7b4dcc12b.dcm", "http://localhost:8000/media/atlas/dicom/8d18ac56914b9de72b8aecced09654ec4b2c9bb7.dcm", "http://localhost:8000/media/atlas/dicom/74ce708a90dff0636fc4bc914fe47e8b6d9320bd.dcm", "http://localhost:8000/media/atlas/dicom/a96b6c9900570e076edf849a0ae3b52734ed2650.dcm", "http://localhost:8000/media/atlas/dicom/0183718d9336458478ec42130395d78e01492bd6.dcm", "http://localhost:8000/media/atlas/dicom/52eee968bbce5590274346eed43f1837f9daba11.dcm", "http://localhost:8000/media/atlas/dicom/961449f88197566c4643fcfd20f48ea4e810490b.dcm", "http://localhost:8000/media/atlas/dicom/1a16ad5b25e11739eadbcb8ea461072b100113a0.dcm", "http://localhost:8000/media/atlas/dicom/b89ddcc3f499059d2dedb0028a2bb1ce4abf5574.dcm", "http://localhost:8000/media/atlas/dicom/c1379c03a4e449c2fa88784d601cb766af907739.dcm", "http://localhost:8000/media/atlas/dicom/4fb532d5003c59a73f7e4248f365b1a8660ea710.dcm", "http://localhost:8000/media/atlas/dicom/e861b0de042a1f213cbccc995679aacd29c7ef53.dcm", "http://localhost:8000/media/atlas/dicom/1a854282afe264c3afff6a6e287cd646b2193a0c.dcm", "http://localhost:8000/media/atlas/dicom/f59d6d9bc33160abad3af8e2b246179b3574d884.dcm", "http://localhost:8000/media/atlas/dicom/04fadab0ed8b3f1925e601c4f9cfb792c95626c2.dcm", "http://localhost:8000/media/atlas/dicom/d45d0b12fb650e65daeeee4e5fa3b21c988597f6.dcm", "http://localhost:8000/media/atlas/dicom/e19c47b7787ac45dbc225319dfa143dc2c1fdf1f.dcm", "http://localhost:8000/media/atlas/dicom/dccabf3103a5be254a45457ce2d8d9ce9cb19a6a.dcm"]"
|
||||||
data-auto-cache-stack="true"
|
data-auto-cache-stack="true"
|
||||||
|
|||||||
+213
-6
@@ -220,6 +220,38 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
() => Array(MAX_VIEWPORTS).fill(0)
|
() => Array(MAX_VIEWPORTS).fill(0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Client-side generated thumbnails cache: stackIndex -> dataURL
|
||||||
|
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
|
||||||
|
const thumbRequestedRef = useRef<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// Helper to find a canvas element under a given node (recursive)
|
||||||
|
function findCanvasIn(node: ParentNode | null): HTMLCanvasElement | null {
|
||||||
|
if (!node) return null;
|
||||||
|
try {
|
||||||
|
if ((node as Element).tagName && (node as Element).tagName.toLowerCase() === 'canvas') {
|
||||||
|
return node as unknown as HTMLCanvasElement;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const q = (node as Element).querySelector && (node as Element).querySelector('canvas');
|
||||||
|
if (q) return q as HTMLCanvasElement;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const children = (node as Element).children || [];
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const found = findCanvasIn(children[i]);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [stackOrderModified, setStackOrderModified] = useState(false);
|
const [stackOrderModified, setStackOrderModified] = useState(false);
|
||||||
const [loadingState, setLoadingState] = useState<null | "annotations" | "viewerState">(null);
|
const [loadingState, setLoadingState] = useState<null | "annotations" | "viewerState">(null);
|
||||||
// Load annotations and viewer state from props if provided
|
// Load annotations and viewer state from props if provided
|
||||||
@@ -398,6 +430,147 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null) => {
|
||||||
|
if (!chosenImageId) {
|
||||||
|
console.warn('generateThumbnail: no chosenImageId for stack', stackIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// avoid regenerating
|
||||||
|
if (stackThumbnails[stackIdx]) {
|
||||||
|
// already have it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thumbRequestedRef.current[stackIdx]) {
|
||||||
|
console.debug('generateThumbnail: already requested for', stackIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderingEngine = renderingEngineRef.current;
|
||||||
|
if (!renderingEngine) {
|
||||||
|
console.warn('generateThumbnail: no renderingEngine yet for', stackIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// mark requested once we are sure the rendering engine exists
|
||||||
|
thumbRequestedRef.current[stackIdx] = true;
|
||||||
|
console.debug('generateThumbnail: starting for', stackIdx, chosenImageId);
|
||||||
|
|
||||||
|
const viewportId = `THUMB_${container_id || 'main'}_${stackIdx}`;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.width = '160px';
|
||||||
|
el.style.height = '160px';
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.style.left = '-9999px';
|
||||||
|
el.style.top = '0';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderingEngine.enableElement({ viewportId, type: Enums.ViewportType.STACK, element: el });
|
||||||
|
const vp = renderingEngine.getViewport(viewportId);
|
||||||
|
if (!vp) {
|
||||||
|
console.warn('generateThumbnail: no viewport returned after enableElement for', viewportId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use single-image stack for thumbnail
|
||||||
|
try {
|
||||||
|
vp.setStack([chosenImageId]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('generateThumbnail: vp.setStack failed', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for IMAGE_RENDERED event on the temporary viewport element or timeout
|
||||||
|
const canvasReady = await new Promise<boolean>(resolve => {
|
||||||
|
let resolved = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
console.warn('generateThumbnail: IMAGE_RENDERED timeout for', viewportId);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
const onRendered = () => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.debug('generateThumbnail: IMAGE_RENDERED received for', viewportId);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (vp && vp.element) {
|
||||||
|
try {
|
||||||
|
vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('generateThumbnail: failed to add IMAGE_RENDERED listener', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trigger a render in case it doesn't auto-render.
|
||||||
|
// vp.render may return undefined (not a Promise), so guard before calling .catch()
|
||||||
|
if (typeof vp.render === 'function') {
|
||||||
|
try {
|
||||||
|
const res = vp.render();
|
||||||
|
if (res && typeof (res as any).then === 'function') {
|
||||||
|
(res as any).catch(() => undefined);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore render errors here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.warn('generateThumbnail: error while waiting for render', err);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find canvas inside the temporary element or viewport element. Try multiple locations.
|
||||||
|
let canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
|
||||||
|
if (!canvas) {
|
||||||
|
console.debug('generateThumbnail: no canvas found immediately, waiting briefly and retrying for', viewportId, 'canvasReady=', canvasReady);
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvas) {
|
||||||
|
try {
|
||||||
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
|
setStackThumbnails(prev => ({ ...prev, [stackIdx]: dataUrl }));
|
||||||
|
console.debug('generateThumbnail: thumbnail captured for stack', stackIdx);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('generateThumbnail: toDataURL failed', err);
|
||||||
|
// Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL
|
||||||
|
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('generateThumbnail: no canvas found after render for', viewportId, 'canvasReady=', canvasReady, 'vp.element=', !!(vp && vp.element));
|
||||||
|
// Mark as broken so we don't attempt to use raw .dcm URL as <img> src
|
||||||
|
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// log and mark broken so UI shows placeholder
|
||||||
|
console.warn('Thumbnail generation failed for', chosenImageId, err);
|
||||||
|
try { setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); } catch (e) { /* ignore */ }
|
||||||
|
} finally {
|
||||||
|
try { renderingEngine.disableElement(viewportId); } catch (e) { /* ignore */ }
|
||||||
|
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||||
|
}
|
||||||
|
}, [container_id, stackThumbnails]);
|
||||||
|
|
||||||
|
// Kick off thumbnail generation when stacks change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableStacks || availableStacks.length === 0) return;
|
||||||
|
const renderingEngine = renderingEngineRef.current;
|
||||||
|
if (!renderingEngine) return; // wait until rendering engine exists
|
||||||
|
availableStacks.forEach((stack, idx) => {
|
||||||
|
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
|
||||||
|
const midIndex = Math.floor(imageIds.length / 2);
|
||||||
|
const chosen = imageIds[midIndex] || imageIds[0] || null;
|
||||||
|
if (!stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
|
||||||
|
generateThumbnail(idx, chosen as string | null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [availableStacks, generateThumbnail, stackThumbnails]);
|
||||||
|
|
||||||
// Wrap setLoadedImageIds to also cache DICOM metadata
|
// Wrap setLoadedImageIds to also cache DICOM metadata
|
||||||
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => {
|
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => {
|
||||||
console.log("Setting loaded imageIds:", imageIds);
|
console.log("Setting loaded imageIds:", imageIds);
|
||||||
@@ -937,6 +1110,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
if (!renderingEngine) {
|
if (!renderingEngine) {
|
||||||
renderingEngine = new RenderingEngine(renderingEngineId);
|
renderingEngine = new RenderingEngine(renderingEngineId);
|
||||||
renderingEngineRef.current = renderingEngine;
|
renderingEngineRef.current = renderingEngine;
|
||||||
|
// If stacks were already available before the engine was ready, kick off thumbnails now
|
||||||
|
try {
|
||||||
|
if (availableStacks && availableStacks.length > 0) {
|
||||||
|
availableStacks.forEach((stack, idx) => {
|
||||||
|
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
|
||||||
|
const midIndex = Math.floor(imageIds.length / 2);
|
||||||
|
const chosen = imageIds[midIndex] || imageIds[0] || null;
|
||||||
|
if (chosen && !stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
|
||||||
|
// fire-and-forget
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
generateThumbnail(idx, chosen as string | null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- In your setup function, use the correct tool group per viewport ---
|
// --- In your setup function, use the correct tool group per viewport ---
|
||||||
@@ -3518,7 +3708,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
// Prefer the middle image for a representative thumbnail, fallback to first
|
// Prefer the middle image for a representative thumbnail, fallback to first
|
||||||
const midIndex = Math.floor(imageIds.length / 2);
|
const midIndex = Math.floor(imageIds.length / 2);
|
||||||
const chosenImage = imageIds[midIndex] || imageIds[0];
|
const chosenImage = imageIds[midIndex] || imageIds[0];
|
||||||
const thumbnailUrl = chosenImage ? (chosenImage.startsWith('wadouri:') ? chosenImage.slice(7) : chosenImage) : null;
|
// Remove the wadouri: scheme if present. Use replace to avoid off-by-one slice errors.
|
||||||
|
const thumbnailUrl = chosenImage ? chosenImage.replace(/^wadouri:/, '') : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -3542,11 +3733,27 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{thumbnailUrl ? (
|
{(() => {
|
||||||
<img src={thumbnailUrl} alt={label} style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }} />
|
const generated = stackThumbnails[idx];
|
||||||
) : (
|
// If generation explicitly failed, treat as missing so placeholder is shown
|
||||||
<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>
|
const generatedValid = generated && generated !== 'BROKEN' ? generated : null;
|
||||||
)}
|
const src = generatedValid || thumbnailUrl || null;
|
||||||
|
if (!src) {
|
||||||
|
return (<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }}
|
||||||
|
onError={(e) => {
|
||||||
|
console.warn('Thumbnail <img> failed to load for stack', idx, src);
|
||||||
|
// Mark thumbnail as broken so we render the placeholder instead
|
||||||
|
setStackThumbnails(prev => ({ ...prev, [idx]: 'BROKEN' }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ fontWeight: 600 }}>{label}</div>
|
<div style={{ fontWeight: 600 }}>{label}</div>
|
||||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{count} img{count === 1 ? '' : 's'}</div>
|
<div style={{ fontSize: 12, opacity: 0.7 }}>{count} img{count === 1 ? '' : 's'}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user