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
+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>