Add thumbnail generation for image stacks and improve rendering logic

This commit is contained in:
Ross
2025-09-22 15:13:01 +01:00
parent 8d67078a90
commit 5b3e47edea
2 changed files with 215 additions and 9 deletions
+2 -3
View File
@@ -30,7 +30,7 @@
></div>
<!-- Example A: array of named stacks (simple) -->
<!--
<div
class="dicom-viewer-root"
id="viewer_named_simple"
@@ -54,7 +54,6 @@
style="width:600px;height:400px;"
></div>
<!-- Example B: nested cases with multiple stacks per case -->
<div
class="dicom-viewer-root"
id="viewer_named_cases"
@@ -93,7 +92,7 @@
}
]'
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;"
data-images="[&quot;http://localhost:8000/media/atlas/dicom/4596ea7654b7a5074d2c3f40039c556b11fb01ae.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/b2fa301bc731d15be3e000402c9a79c114d17aa5.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/66310ef801fbecb37c5ced160c450dfd5ecc6d2d.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/63d2d117bf256a76a61190066f91eda6c4d67c8f.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/181a3575ea7fb8bf8f6449ff61e6e369a3247454.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/7c5c0538339266ac8897fa21c481b2f61172068e.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/1681410681308af7111af6644fed598fde304b96.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/4adcd3d7a64a9c59b530f730ce0d991bf7f27813.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/671e6004c7fb402a627a2282bbf304d7b4dcc12b.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/8d18ac56914b9de72b8aecced09654ec4b2c9bb7.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/74ce708a90dff0636fc4bc914fe47e8b6d9320bd.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/a96b6c9900570e076edf849a0ae3b52734ed2650.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/0183718d9336458478ec42130395d78e01492bd6.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/52eee968bbce5590274346eed43f1837f9daba11.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/961449f88197566c4643fcfd20f48ea4e810490b.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/1a16ad5b25e11739eadbcb8ea461072b100113a0.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/b89ddcc3f499059d2dedb0028a2bb1ce4abf5574.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/c1379c03a4e449c2fa88784d601cb766af907739.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/4fb532d5003c59a73f7e4248f365b1a8660ea710.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/e861b0de042a1f213cbccc995679aacd29c7ef53.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/1a854282afe264c3afff6a6e287cd646b2193a0c.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/f59d6d9bc33160abad3af8e2b246179b3574d884.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/04fadab0ed8b3f1925e601c4f9cfb792c95626c2.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/d45d0b12fb650e65daeeee4e5fa3b21c988597f6.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/e19c47b7787ac45dbc225319dfa143dc2c1fdf1f.dcm&quot;, &quot;http://localhost:8000/media/atlas/dicom/dccabf3103a5be254a45457ce2d8d9ce9cb19a6a.dcm&quot;]"
data-auto-cache-stack="true"
+213 -6
View File
@@ -220,6 +220,38 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
() => 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 [loadingState, setLoadingState] = useState<null | "annotations" | "viewerState">(null);
// 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
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => {
console.log("Setting loaded imageIds:", imageIds);
@@ -937,6 +1110,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
if (!renderingEngine) {
renderingEngine = new RenderingEngine(renderingEngineId);
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 ---
@@ -3518,7 +3708,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
// Prefer the middle image for a representative thumbnail, fallback to first
const midIndex = Math.floor(imageIds.length / 2);
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 (
<div
@@ -3542,11 +3733,27 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}}
>
<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' }} />
) : (
<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>
)}
{(() => {
const generated = stackThumbnails[idx];
// If generation explicitly failed, treat as missing so placeholder is shown
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={{ fontWeight: 600 }}>{label}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{count} img{count === 1 ? '' : 's'}</div>