Add thumbnail generation for image stacks and improve rendering logic
This commit is contained in:
@@ -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="["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"
|
||||
|
||||
+213
-6
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user