diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 527207a..a82f389 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -30,6 +30,7 @@ declare global { cornerstone3dTools: typeof cornerstoneTools; cornerstone3d: typeof cornerstone3d; cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader; + __DV3D_THUMB_VERBOSE__?: boolean; } } @@ -160,6 +161,17 @@ const TOOL_OPTIONS = [ { label: "Reference Cursors", value: ReferenceCursors.toolName, icon: "⦿" }, ]; +const THUMB_DEBUG_BUFFER_MAX = 500; +const THUMB_CAPTURE_MAX_RETRIES = 3; +const THUMB_CAPTURE_RETRY_BASE_MS = 220; +const THUMB_DEBUG_HIGH_FREQ_EVENTS = new Set([ + 'stackThumbnails-state', + 'thumbnail-generation-check', + 'thumbnail-candidate', + 'thumbnail-render-source', + 'thumbnail-img-load', +]); + function getToolIcon(toolName?: string) { if (!toolName) return '🔖'; const found = TOOL_OPTIONS.find(o => o.value === toolName || o.label === toolName); @@ -172,6 +184,66 @@ function stripWadouriPrefix(imageId?: string): string { return imageId.replace(/^wadouri:+/, "").replace(/^:+/, ""); } +function truncateForDebug(value: string, maxLen = 180): string { + if (!value) return value; + if (value.length <= maxLen) return value; + return `${value.slice(0, maxLen)}...(${value.length} chars)`; +} + +function isLikelyDicomThumbnailSource(src?: string | null): boolean { + if (!src) return false; + const lower = src.toLowerCase(); + if (lower.startsWith('wadouri:')) return true; + if (lower.includes('.dcm')) return true; + if (lower.includes('/dicom') || lower.includes('/wado')) return true; + return false; +} + +function isCanvasLikelyReadyForThumbnail(canvas: HTMLCanvasElement): boolean { + try { + const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings); + if (!ctx) return true; + + const width = canvas.width || 160; + const height = canvas.height || 160; + const imageData = ctx.getImageData(0, 0, width, height); + const data = imageData.data; + + let minLum = 255; + let maxLum = 0; + let nonDarkCount = 0; + let sampleCount = 0; + + for (let y = 0; y < height; y += 8) { + for (let x = 0; x < width; x += 8) { + const i = (y * width + x) * 4; + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + const lum = (r + g + b) / 3; + + if (a > 0 && lum > 12) { + nonDarkCount += 1; + } + + minLum = Math.min(minLum, lum); + maxLum = Math.max(maxLum, lum); + sampleCount += 1; + } + } + + if (sampleCount === 0) return true; + + const nonDarkRatio = nonDarkCount / sampleCount; + const dynamicRange = maxLum - minLum; + return nonDarkRatio > 0.015 || dynamicRange > 8; + } catch (e) { + // If pixel inspection fails, do not block thumbnail generation. + return true; + } +} + // Mouse button labels const MOUSE_BUTTONS = [ { label: "Left", value: "Primary" }, @@ -281,10 +353,59 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const [stackThumbnails, setStackThumbnails] = useState>({}); const stackThumbnailsRef = useRef>({}); const thumbRequestedRef = useRef>({}); + const thumbDebugPrefix = `[DV3D Thumb][${container_id || 'default'}]`; + const thumbDebugBufferRef = useRef>>([]); + const thumbDebugLastLogRef = useRef>({}); + + const thumbDebug = useCallback((event: string, details?: Record) => { + try { + const payload = details || {}; + const isHighFrequency = THUMB_DEBUG_HIGH_FREQ_EVENTS.has(event); + const stackPart = typeof payload.stackIdx === 'number' ? String(payload.stackIdx) : (typeof payload.idx === 'number' ? String(payload.idx) : 'global'); + const key = `${event}:${stackPart}`; + const now = Date.now(); + const last = thumbDebugLastLogRef.current[key] || 0; + const verbose = typeof window !== 'undefined' && !!window.__DV3D_THUMB_VERBOSE__; + + if (isHighFrequency && !verbose && now - last < 2500) { + return; + } + + thumbDebugLastLogRef.current[key] = now; + + const entry = { + ts: new Date().toISOString(), + event, + container: container_id || 'default', + path: typeof window !== 'undefined' ? window.location.pathname : '', + details: payload, + }; + + thumbDebugBufferRef.current.push(entry); + if (thumbDebugBufferRef.current.length > THUMB_DEBUG_BUFFER_MAX) { + thumbDebugBufferRef.current.shift(); + } + + console.debug(thumbDebugPrefix, event, payload); + } catch (e) { + // ignore debug logging failures + } + }, [thumbDebugPrefix, container_id]); const setStackThumbnail = useCallback((stackIdx: number, value: string) => { setStackThumbnails(prev => { const previous = prev[stackIdx]; + try { + console.debug(thumbDebugPrefix, 'setStackThumbnail', { + stackIdx, + previous: previous ? truncateForDebug(previous, 120) : null, + next: value ? truncateForDebug(value, 120) : null, + previousWasBlob: !!(previous && previous.startsWith('blob:')), + nextIsBlob: !!(value && value.startsWith('blob:')), + }); + } catch (e) { + // ignore debug logging failures + } if (previous && previous !== value && previous.startsWith('blob:')) { try { URL.revokeObjectURL(previous); @@ -296,11 +417,18 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer stackThumbnailsRef.current = next; return next; }); - }, []); + }, [thumbDebugPrefix]); useEffect(() => { stackThumbnailsRef.current = stackThumbnails; - }, [stackThumbnails]); + const entries = Object.entries(stackThumbnails).map(([idx, value]) => ({ + idx: Number(idx), + value: truncateForDebug(value, 100), + isBlob: value.startsWith('blob:'), + isBroken: value === 'BROKEN', + })); + thumbDebug('stackThumbnails-state', { count: entries.length, entries }); + }, [stackThumbnails, thumbDebug]); useEffect(() => { return () => { @@ -685,6 +813,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }) ); + thumbDebug('imageStacks-loaded', { + descriptorCount: stacksRaw.length, + normalizedCount: normalized.length, + summary: normalized.map((stack, idx) => { + const ids = Array.isArray(stack.imageIds) ? stack.imageIds : []; + const mid = ids[Math.floor(ids.length / 2)] || ids[0] || ''; + return { + idx, + name: stack.name || null, + caseId: stack.caseId || null, + studyId: stack.studyId || null, + imageCount: ids.length, + sampleImageId: truncateForDebug(mid, 180), + }; + }), + }); + setAvailableStacks(normalized); // Seed viewports with first stack if present (preserve old behavior) @@ -697,12 +842,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }) .catch(err => { console.error("Failed to load imageStacks:", err); + thumbDebug('imageStacks-load-failed', { + error: String((err as any)?.message || err), + }); setAvailableStacks([]); setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); }); return () => { mounted = false; }; - }, []); + }, [thumbDebug]); const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const stackObj = availableStacks[stackIdx]; @@ -784,24 +932,34 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }); }; - const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null) => { + const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null, retryAttempt = 0) => { if (!chosenImageId) { console.warn('generateThumbnail: no chosenImageId for stack', stackIdx); + thumbDebug('generate-skip-no-image', { stackIdx }); return; } // avoid regenerating if (stackThumbnails[stackIdx]) { console.debug('generateThumbnail: thumbnail already exists for', stackIdx); + thumbDebug('generate-skip-existing', { + stackIdx, + existing: truncateForDebug(stackThumbnails[stackIdx], 120), + }); // already have it return; } if (thumbRequestedRef.current[stackIdx]) { console.debug('generateThumbnail: already requested for', stackIdx); + thumbDebug('generate-skip-requested', { stackIdx }); return; } // If the chosen image is an external non-DICOM image, draw it to a canvas directly if (isExternalImageId(chosenImageId)) { + thumbDebug('generate-external-start', { + stackIdx, + chosenImageId: truncateForDebug(chosenImageId, 180), + }); try { const img = new Image(); img.crossOrigin = 'anonymous'; @@ -833,6 +991,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } } catch (err) { console.warn('generateThumbnail: failed to generate thumbnail for external image', err); + thumbDebug('generate-external-failed', { + stackIdx, + chosenImageId: truncateForDebug(chosenImageId, 180), + error: String((err as any)?.message || err), + }); setStackThumbnail(stackIdx, 'BROKEN'); } return; @@ -841,6 +1004,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const renderingEngine = renderingEngineRef.current; if (!renderingEngine) { console.warn('generateThumbnail: no renderingEngine yet for', stackIdx); + thumbDebug('generate-skip-no-engine', { + stackIdx, + chosenImageId: truncateForDebug(chosenImageId, 180), + }); return; } // mark requested once we are sure the rendering engine exists @@ -848,6 +1015,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer console.debug('generateThumbnail: starting for', stackIdx, chosenImageId); const viewportId = `THUMB_${container_id || 'main'}_${stackIdx}`; + thumbDebug('generate-start', { + stackIdx, + chosenImageId: truncateForDebug(chosenImageId, 180), + viewportId, + retryAttempt, + }); const el = document.createElement('div'); el.style.width = '160px'; el.style.height = '160px'; @@ -861,6 +1034,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const vp = renderingEngine.getViewport(viewportId); if (!vp) { console.warn('generateThumbnail: no viewport returned after enableElement for', viewportId); + thumbDebug('generate-failed-no-viewport', { stackIdx, viewportId }); return; } // Use single-image stack for thumbnail @@ -868,6 +1042,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer vp.setStack([chosenImageId]); } catch (err) { console.warn('generateThumbnail: vp.setStack failed', err); + thumbDebug('generate-setStack-failed', { + stackIdx, + viewportId, + error: String((err as any)?.message || err), + }); } // Wait for IMAGE_RENDERED event on the temporary viewport element or timeout @@ -877,6 +1056,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer if (!resolved) { resolved = true; console.warn('generateThumbnail: IMAGE_RENDERED timeout for', viewportId); + thumbDebug('generate-render-timeout', { stackIdx, viewportId }); resolve(false); } }, 2000); @@ -886,6 +1066,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer resolved = true; clearTimeout(timeout); console.debug('generateThumbnail: IMAGE_RENDERED received for', viewportId); + thumbDebug('generate-rendered', { stackIdx, viewportId }); resolve(true); }; @@ -895,6 +1076,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any); } catch (err) { console.warn('generateThumbnail: failed to add IMAGE_RENDERED listener', err); + thumbDebug('generate-listener-failed', { + stackIdx, + viewportId, + error: String((err as any)?.message || err), + }); } } // Trigger a render in case it doesn't auto-render. @@ -912,6 +1098,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } catch (err) { clearTimeout(timeout); console.warn('generateThumbnail: error while waiting for render', err); + thumbDebug('generate-render-wait-error', { + stackIdx, + viewportId, + error: String((err as any)?.message || err), + }); resolve(false); } }); @@ -920,11 +1111,43 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer 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); + thumbDebug('generate-canvas-missing-initial', { stackIdx, viewportId, canvasReady }); await new Promise(r => setTimeout(r, 150)); canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null); } if (canvas) { + thumbDebug('generate-canvas-found', { + stackIdx, + viewportId, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + }); + + const readyForCapture = isCanvasLikelyReadyForThumbnail(canvas); + if (!readyForCapture) { + if (retryAttempt < THUMB_CAPTURE_MAX_RETRIES) { + const retryDelayMs = THUMB_CAPTURE_RETRY_BASE_MS * (retryAttempt + 1); + thumbDebug('generate-canvas-not-ready-retry', { + stackIdx, + viewportId, + retryAttempt, + retryDelayMs, + }); + thumbRequestedRef.current[stackIdx] = false; + setTimeout(() => { + generateThumbnail(stackIdx, chosenImageId, retryAttempt + 1); + }, retryDelayMs); + return; + } + + thumbDebug('generate-canvas-not-ready-final-attempt', { + stackIdx, + viewportId, + retryAttempt, + }); + } + try { if (typeof canvas.toBlob === 'function') { const blobUrl = await new Promise((resolve) => { @@ -937,50 +1160,87 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }, 'image/png'); }); if (blobUrl) { + thumbDebug('generate-toBlob-success', { + stackIdx, + viewportId, + blobUrl: truncateForDebug(blobUrl, 120), + }); setStackThumbnail(stackIdx, blobUrl); } else { + thumbDebug('generate-toBlob-empty-fallback-dataUrl', { stackIdx, viewportId }); setStackThumbnail(stackIdx, canvas.toDataURL('image/png')); } } else { + thumbDebug('generate-no-toBlob-fallback-dataUrl', { stackIdx, viewportId }); setStackThumbnail(stackIdx, canvas.toDataURL('image/png')); } console.debug('generateThumbnail: thumbnail captured for stack', stackIdx); + thumbDebug('generate-success', { stackIdx, viewportId }); } catch (err) { console.warn('generateThumbnail: toDataURL failed', err); + thumbDebug('generate-data-extract-failed', { + stackIdx, + viewportId, + error: String((err as any)?.message || err), + }); // Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL setStackThumbnail(stackIdx, 'BROKEN'); } } else { console.warn('generateThumbnail: no canvas found after render for', viewportId, 'canvasReady=', canvasReady, 'vp.element=', !!(vp && vp.element)); + thumbDebug('generate-failed-no-canvas', { + stackIdx, + viewportId, + canvasReady, + hasViewportElement: !!(vp && vp.element), + }); // Mark as broken so we don't attempt to use raw .dcm URL as src setStackThumbnail(stackIdx, 'BROKEN'); } } catch (err) { // log and mark broken so UI shows placeholder console.warn('Thumbnail generation failed for', chosenImageId, err); + thumbDebug('generate-failed', { + stackIdx, + chosenImageId: truncateForDebug(chosenImageId || '', 180), + error: String((err as any)?.message || err), + }); try { setStackThumbnail(stackIdx, 'BROKEN'); } catch (e) { /* ignore */ } } finally { try { renderingEngine.disableElement(viewportId); } catch (e) { console.warn('Failed to disable thumbnail element:', e); } + thumbDebug('generate-cleanup', { stackIdx, viewportId }); if (el && el.parentNode) el.parentNode.removeChild(el); } - }, [container_id, stackThumbnails, setStackThumbnail]); + }, [container_id, stackThumbnails, setStackThumbnail, thumbDebug]); // Kick off thumbnail generation when stacks change useEffect(() => { if (!availableStacks || availableStacks.length === 0) return; + thumbDebug('thumbnail-generation-check', { + stackCount: availableStacks.length, + requested: { ...thumbRequestedRef.current }, + thumbnailKeys: Object.keys(stackThumbnails), + }); 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; + thumbDebug('thumbnail-candidate', { + idx, + imageCount: imageIds.length, + chosen: truncateForDebug(chosen || '', 180), + hasThumbnail: !!stackThumbnails[idx], + requested: !!thumbRequestedRef.current[idx], + }); if (!stackThumbnails[idx] && !thumbRequestedRef.current[idx]) { generateThumbnail(idx, chosen as string | null); } }); - }, [availableStacks, generateThumbnail, stackThumbnails]); + }, [availableStacks, generateThumbnail, stackThumbnails, thumbDebug]); // Wrap setLoadedImageIds to also cache DICOM metadata const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => { @@ -3534,6 +3794,18 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer updateOverlay(activeViewport ?? 0, viewport); }; + window[`exportThumbDebug_${apiKey}`] = () => { + try { + return JSON.stringify(thumbDebugBufferRef.current, null, 2); + } catch (e) { + return '[]'; + } + }; + window[`clearThumbDebug_${apiKey}`] = () => { + thumbDebugBufferRef.current = []; + return true; + }; + // Cleanup @@ -3552,6 +3824,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer window[`loadAdditionalStack_${apiKey}`] = undefined; window[`getViewport_${apiKey}`] = undefined; window[`getAllViewports_${apiKey}`] = undefined; + window[`exportThumbDebug_${apiKey}`] = undefined; + window[`clearThumbDebug_${apiKey}`] = undefined; }; }, [ viewportGrid, @@ -5358,18 +5632,66 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // If generation explicitly failed, treat as missing so placeholder is shown const generatedValid = generated && generated !== 'BROKEN' ? generated : null; const src = generatedValid || thumbnailUrl || null; + thumbDebug('thumbnail-render-source', { + idx, + label, + generated: generated ? truncateForDebug(generated, 120) : null, + generatedValid: generatedValid ? truncateForDebug(generatedValid, 120) : null, + fallback: thumbnailUrl ? truncateForDebug(thumbnailUrl, 180) : null, + chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null, + src: src ? truncateForDebug(src, 180) : null, + requested: !!thumbRequestedRef.current[idx], + }); if (!src) { return (
No Img
); } return ( {label} { + onLoad={(e) => { + const el = e.currentTarget; + thumbDebug('thumbnail-img-load', { + idx, + label, + src: truncateForDebug(src, 180), + naturalWidth: el.naturalWidth, + naturalHeight: el.naturalHeight, + clientWidth: el.clientWidth, + clientHeight: el.clientHeight, + }); + }} + onError={() => { console.warn('Thumbnail failed to load for stack', idx, src); - // Mark thumbnail as broken so we render the placeholder instead - setStackThumbnail(idx, 'BROKEN'); + const usedGeneratedImage = !!generatedValid; + const likelyDicomFallback = !usedGeneratedImage && isLikelyDicomThumbnailSource(src); + thumbDebug('thumbnail-img-error', { + idx, + label, + src: truncateForDebug(src, 180), + usedGeneratedImage, + likelyDicomFallback, + requested: !!thumbRequestedRef.current[idx], + chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null, + }); + + if (usedGeneratedImage) { + // Generated image failed: mark as broken to avoid endless reload attempts. + setStackThumbnail(idx, 'BROKEN'); + return; + } + + // Fallback URL failed (often raw DICOM). Do not mark BROKEN yet; + // request generation and keep the thumbnail pipeline alive. + if (!thumbRequestedRef.current[idx]) { + thumbDebug('thumbnail-img-error-trigger-generate', { + idx, + chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null, + }); + generateThumbnail(idx, chosenImage as string | null); + } }} /> ); diff --git a/dicom-viewer/src/main.tsx b/dicom-viewer/src/main.tsx index fd556fe..41a6fc7 100644 --- a/dicom-viewer/src/main.tsx +++ b/dicom-viewer/src/main.tsx @@ -53,6 +53,7 @@ export function mountDicomViewers() { const containers = document.querySelectorAll(".dicom-viewer-root"); containers.forEach((container) => { const id = container.id || ''; + const mountPrefix = `[DV3D Mount][${id || 'default'}]`; let imageStacks: () => Promise; const isEmpty = container.getAttribute('data-empty') === 'true'; @@ -60,6 +61,20 @@ export function mountDicomViewers() { const dataNamed = container.getAttribute('data-named-stacks'); const dataImages = container.getAttribute('data-images'); + try { + console.debug(mountPrefix, 'container-attrs', { + path: window.location.pathname, + isEmpty, + hasDataNamedStacks: !!dataNamed, + hasDataImages: !!dataImages, + dataNamedLength: dataNamed ? dataNamed.length : 0, + dataImagesLength: dataImages ? dataImages.length : 0, + autoCacheStack: container.getAttribute('data-auto-cache-stack'), + }); + } catch (e) { + // ignore debug logging failures + } + const parseToDescriptors = (parsed: any): Array => { // Return array of descriptors: either simple string[] => { imageIds: string[] } // or objects: { imageIds: string[], name?, caseId?, studyId? } @@ -143,6 +158,25 @@ export function mountDicomViewers() { parsed = []; } const descriptors = parseToDescriptors(parsed); + try { + console.debug(mountPrefix, 'parsed-data-named-stacks', { + descriptorCount: descriptors.length, + summary: descriptors.map((d, idx) => { + const imageIds = Array.isArray(d.imageIds) ? d.imageIds : []; + const sample = imageIds[Math.floor(imageIds.length / 2)] || imageIds[0] || ''; + return { + idx, + name: d.name || null, + caseId: d.caseId || null, + studyId: d.studyId || null, + imageCount: imageIds.length, + sampleImageId: sample.length > 180 ? `${sample.slice(0, 180)}...(${sample.length} chars)` : sample, + }; + }), + }); + } catch (e) { + // ignore debug logging failures + } imageStacks = () => Promise.resolve(descriptors); } else if (dataImages) { let parsed; @@ -152,6 +186,14 @@ export function mountDicomViewers() { console.error("Invalid data-images JSON:", e); parsed = []; } + try { + console.debug(mountPrefix, 'parsed-data-images', { + topLevelIsArray: Array.isArray(parsed), + topLevelLength: Array.isArray(parsed) ? parsed.length : 0, + }); + } catch (e) { + // ignore debug logging failures + } // Keep old behavior for data-images if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") { imageStacks = () => Promise.resolve([toWadouri(parsed)]); @@ -174,6 +216,17 @@ export function mountDicomViewers() { const viewerState = container.getAttribute('data-viewerstate'); console.log("Mounting DICOM viewer:") console.log(viewerState, annotationJson, autoCacheStack); + try { + console.debug(mountPrefix, 'mount-props', { + hasViewerState: !!viewerState, + viewerStateLength: viewerState ? viewerState.length : 0, + hasAnnotationJson: !!annotationJson, + annotationJsonLength: annotationJson ? annotationJson.length : 0, + autoCacheStack, + }); + } catch (e) { + // ignore debug logging failures + } createRoot(container).render(