Add support for external image loading and enhance pan/zoom interactions
This commit is contained in:
+100
-13
@@ -2,8 +2,10 @@ import { useEffect, useRef, useCallback, useState } from "react"
|
|||||||
import {
|
import {
|
||||||
RenderingEngine,
|
RenderingEngine,
|
||||||
Enums,
|
Enums,
|
||||||
|
imageLoader,
|
||||||
|
utilities as csUtilities,
|
||||||
} from "@cornerstonejs/core"
|
} from "@cornerstonejs/core"
|
||||||
import { init as csRenderInit, utilities as csUtilities } from "@cornerstonejs/core"
|
import { init as csRenderInit } from "@cornerstonejs/core"
|
||||||
import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums, PanTool, WindowLevelTool, StackScrollTool, ZoomTool, PlanarRotateTool, utilities as csToolsUtilities } from "@cornerstonejs/tools"
|
import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums, PanTool, WindowLevelTool, StackScrollTool, ZoomTool, PlanarRotateTool, utilities as csToolsUtilities } from "@cornerstonejs/tools"
|
||||||
import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool, PlanarFreehandROITool, PlanarFreehandContourSegmentationTool, SculptorTool, CrosshairsTool, ReferenceLinesTool, ReferenceCursors } from "@cornerstonejs/tools"
|
import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool, PlanarFreehandROITool, PlanarFreehandContourSegmentationTool, SculptorTool, CrosshairsTool, ReferenceLinesTool, ReferenceCursors } from "@cornerstonejs/tools"
|
||||||
import { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
|
import { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
|
||||||
@@ -257,6 +259,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
if (!imageId) return false;
|
if (!imageId) return false;
|
||||||
// Treat wadouri: as DICOM (our existing loader)
|
// Treat wadouri: as DICOM (our existing loader)
|
||||||
if (imageId.startsWith('wadouri:')) return false;
|
if (imageId.startsWith('wadouri:')) return false;
|
||||||
|
if (imageId.startsWith('external:')) return true;
|
||||||
// blob: or data:image/ or common extensions
|
// blob: or data:image/ or common extensions
|
||||||
if (imageId.startsWith('blob:') || imageId.startsWith('data:image/')) return true;
|
if (imageId.startsWith('blob:') || imageId.startsWith('data:image/')) return true;
|
||||||
const lower = imageId.toLowerCase();
|
const lower = imageId.toLowerCase();
|
||||||
@@ -266,7 +269,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
// Enable simple pan & zoom interactions for an <img> placed inside a viewport element.
|
// Enable simple pan & zoom interactions for an <img> placed inside a viewport element.
|
||||||
// Attaches event listeners and returns a cleanup function.
|
// Attaches event listeners and returns a cleanup function.
|
||||||
function enableImagePanZoom(container: HTMLElement, img: HTMLImageElement) {
|
function enableImagePanZoom(container: HTMLElement, img: HTMLImageElement, options?: { allowPan?: boolean, allowZoom?: boolean }) {
|
||||||
|
const allowPanInit = options?.allowPan ?? true;
|
||||||
|
const allowZoomInit = options?.allowZoom ?? true;
|
||||||
let scale = 1;
|
let scale = 1;
|
||||||
let translateX = 0;
|
let translateX = 0;
|
||||||
let translateY = 0;
|
let translateY = 0;
|
||||||
@@ -286,7 +291,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let allowPan = allowPanInit;
|
||||||
|
let allowZoom = allowZoomInit;
|
||||||
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (!allowZoom) return;
|
||||||
// Zoom on wheel
|
// Zoom on wheel
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const rect = img.getBoundingClientRect();
|
const rect = img.getBoundingClientRect();
|
||||||
@@ -305,6 +314,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
const onMouseDown = (e: MouseEvent) => {
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
if (!allowPan) return;
|
||||||
isPanning = true;
|
isPanning = true;
|
||||||
startX = translateX;
|
startX = translateX;
|
||||||
startY = translateY;
|
startY = translateY;
|
||||||
@@ -345,9 +355,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
const onTouchStart = (e: TouchEvent) => {
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
if (e.touches.length === 1) {
|
if (e.touches.length === 1) {
|
||||||
isPanning = true;
|
if (allowPan) {
|
||||||
lastClientX = e.touches[0].clientX;
|
isPanning = true;
|
||||||
lastClientY = e.touches[0].clientY;
|
lastClientX = e.touches[0].clientX;
|
||||||
|
lastClientY = e.touches[0].clientY;
|
||||||
|
}
|
||||||
} else if (e.touches.length === 2) {
|
} else if (e.touches.length === 2) {
|
||||||
lastTouchDist = getTouchDist(e.touches[0], e.touches[1]);
|
lastTouchDist = getTouchDist(e.touches[0], e.touches[1]);
|
||||||
}
|
}
|
||||||
@@ -362,7 +374,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
lastClientX = e.touches[0].clientX;
|
lastClientX = e.touches[0].clientX;
|
||||||
lastClientY = e.touches[0].clientY;
|
lastClientY = e.touches[0].clientY;
|
||||||
apply();
|
apply();
|
||||||
} else if (e.touches.length === 2) {
|
} else if (e.touches.length === 2 && allowZoom) {
|
||||||
const curDist = getTouchDist(e.touches[0], e.touches[1]);
|
const curDist = getTouchDist(e.touches[0], e.touches[1]);
|
||||||
if (lastTouchDist > 0) {
|
if (lastTouchDist > 0) {
|
||||||
const zoomFactor = curDist / lastTouchDist;
|
const zoomFactor = curDist / lastTouchDist;
|
||||||
@@ -412,7 +424,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
img.style.willChange = '';
|
img.style.willChange = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
return cleanup;
|
// Return control functions so we can update allowed interactions later
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
setAllowPan: (v: boolean) => { allowPan = v; },
|
||||||
|
setAllowZoom: (v: boolean) => { allowZoom = v; },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [stackOrderModified, setStackOrderModified] = useState(false);
|
const [stackOrderModified, setStackOrderModified] = useState(false);
|
||||||
@@ -614,7 +631,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
try {
|
try {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
img.src = chosenImageId as string;
|
const src = (chosenImageId as string).replace(/^external:/, '');
|
||||||
|
img.src = src;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
img.onload = () => resolve(true);
|
img.onload = () => resolve(true);
|
||||||
img.onerror = (e) => reject(e);
|
img.onerror = (e) => reject(e);
|
||||||
@@ -1144,6 +1162,28 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
toolGroup.setToolActive(tool, { bindings });
|
toolGroup.setToolActive(tool, { bindings });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also update external image handlers (pan/zoom) to respect selected tools
|
||||||
|
try {
|
||||||
|
const primaryTool = mouseToolBindings.Primary;
|
||||||
|
const wheelTool = mouseToolBindings.Wheel;
|
||||||
|
const auxTool = mouseToolBindings.Auxiliary;
|
||||||
|
// Determine whether pan/zoom should be enabled for primary/wheel interactions
|
||||||
|
const allowPan = primaryTool === PanTool.toolName || auxTool === PanTool.toolName;
|
||||||
|
const allowZoom = primaryTool === ZoomTool.toolName || wheelTool === ZoomTool.toolName || mouseToolBindings.Primary_and_Secondary === ZoomTool.toolName;
|
||||||
|
|
||||||
|
// Iterate viewports and update external handlers
|
||||||
|
viewportRefs.current.forEach((el) => {
|
||||||
|
if (!el) return;
|
||||||
|
const ext = (el as any)._externalPanZoom;
|
||||||
|
if (ext) {
|
||||||
|
if (typeof ext.setAllowPan === 'function') ext.setAllowPan(!!allowPan);
|
||||||
|
if (typeof ext.setAllowZoom === 'function') ext.setAllowZoom(!!allowZoom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
//// Always re-apply the mouse wheel binding for StackScrollTool
|
//// Always re-apply the mouse wheel binding for StackScrollTool
|
||||||
//toolGroup.setToolActive(StackScrollTool.toolName, {
|
//toolGroup.setToolActive(StackScrollTool.toolName, {
|
||||||
// bindings: [
|
// bindings: [
|
||||||
@@ -1187,11 +1227,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create imageIds: use wadouri: for DICOM files, direct blob URLs for image files
|
// Create imageIds: use wadouri: for DICOM files, `external:` for image files
|
||||||
const imageIds = fileArray.map((file) => {
|
const imageIds = fileArray.map((file) => {
|
||||||
const isImage = file.type && file.type.startsWith('image/');
|
const isImage = file.type && file.type.startsWith('image/');
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return URL.createObjectURL(file);
|
return `external:${URL.createObjectURL(file)}`;
|
||||||
}
|
}
|
||||||
return `wadouri:${URL.createObjectURL(file)}`;
|
return `wadouri:${URL.createObjectURL(file)}`;
|
||||||
});
|
});
|
||||||
@@ -1321,6 +1361,53 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
await csToolsInit(
|
await csToolsInit(
|
||||||
);
|
);
|
||||||
dicomImageLoaderInit();
|
dicomImageLoaderInit();
|
||||||
|
// Register client-side image loader for external images (JPEG/PNG/blob/data)
|
||||||
|
try {
|
||||||
|
imageLoader.registerImageLoader('external', async (imageId: string) => {
|
||||||
|
// imageId expected as 'external:<url>'
|
||||||
|
const url = imageId.replace(/^external:/, '');
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.src = url;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
img.onload = () => resolve(true);
|
||||||
|
img.onerror = (e) => reject(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const w = img.naturalWidth || img.width;
|
||||||
|
const h = img.naturalHeight || img.height;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Unable to create canvas context');
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
const im = ctx.getImageData(0, 0, w, h);
|
||||||
|
|
||||||
|
const image = {
|
||||||
|
imageId,
|
||||||
|
minPixelValue: 0,
|
||||||
|
maxPixelValue: 255,
|
||||||
|
slope: 1.0,
|
||||||
|
intercept: 0,
|
||||||
|
rows: h,
|
||||||
|
columns: w,
|
||||||
|
height: h,
|
||||||
|
width: w,
|
||||||
|
color: true,
|
||||||
|
rgba: true,
|
||||||
|
sizeInBytes: im.data.length,
|
||||||
|
getPixelData: () => im.data,
|
||||||
|
// Some Cornerstone APIs expect `getImageData` or `getCanvas` - provide canvas and ImageData
|
||||||
|
getImageData: () => im,
|
||||||
|
getCanvas: () => canvas,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
return image;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to register external image loader', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1438,14 +1525,14 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
img.style.height = '100%';
|
img.style.height = '100%';
|
||||||
img.style.objectFit = 'contain';
|
img.style.objectFit = 'contain';
|
||||||
img.style.display = 'block';
|
img.style.display = 'block';
|
||||||
img.src = firstId as string;
|
img.src = (firstId as string).replace(/^external:/, '');
|
||||||
// allow drag to open in new tab etc.
|
// allow drag to open in new tab etc.
|
||||||
img.setAttribute('draggable', 'false');
|
img.setAttribute('draggable', 'false');
|
||||||
element.appendChild(img);
|
element.appendChild(img);
|
||||||
|
|
||||||
// Attach pan/zoom interactions to this external image
|
// Attach pan/zoom interactions to this external image
|
||||||
const cleanup = enableImagePanZoom(element, img);
|
const ext = enableImagePanZoom(element, img);
|
||||||
(element as any)._externalPanZoom = { cleanup };
|
(element as any)._externalPanZoom = ext;
|
||||||
|
|
||||||
// Do not attach DICOM-specific tools to external images
|
// Do not attach DICOM-specific tools to external images
|
||||||
mainToolGroup.removeViewports(renderingEngineId, viewportId);
|
mainToolGroup.removeViewports(renderingEngineId, viewportId);
|
||||||
|
|||||||
Reference in New Issue
Block a user