diff --git a/docs/configuration_file.md b/docs/configuration_file.md index cfe664980..2f574e207 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -37,7 +37,7 @@ Use a 2D array of view type strings to specify both the grid layout and which vi } ``` -Available view type strings: `"axial"`, `"coronal"`, `"sagittal"`, `"volume"`, `"oblique"` +Available view type strings: `"axial"`, `"coronal"`, `"sagittal"`, `"volume"`, `"oblique"`, `"enface"` ### 2. Simple Grid (gridSize) @@ -111,6 +111,7 @@ View object properties: - 2D views: `type: "2D"`, `orientation: "Axial" | "Coronal" | "Sagittal"`, `name` (optional) - 3D views: `type: "3D"`, `viewDirection` (optional), `viewUp` (optional), `name` (optional) - Oblique views: `type: "Oblique"`, `name` (optional) +- En face views: `type: "EnFace"`, `name` (optional) ### Multiple Layouts Example @@ -147,7 +148,7 @@ Use `disabledViewTypes` to prevent certain view types from being available in th } ``` -This removes the specified view types from the dropdown menu and replaces them in the default layout with allowed types. Valid values: `"2D"`, `"3D"`, `"Oblique"` +This removes the specified view types from the dropdown menu and replaces them in the default layout with allowed types. Valid values: `"2D"`, `"3D"`, `"Oblique"`, `"EnFace"`. ## Segments diff --git a/eslint.config.js b/eslint.config.js index 3476de387..e64605c27 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -310,6 +310,11 @@ export default tseslint.config( }, }, ...featureBoundaries([ + { + dir: 'enface', + publicSurface: false, + pure: ['src/enface/sliceGeometry.ts'], + }, { dir: 'segmentation', pure: [ diff --git a/package-lock.json b/package-lock.json index 50b1f1316..c58d00237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^10.6.2", "fast-deep-equal": "^3.1.3", + "fflate": "0.7.5", "file-saver": "^2.0.5", "gl-matrix": "3.4.3", "globals": "^16.2.0", diff --git a/package.json b/package.json index e8f6351b0..a7ef1682f 100644 --- a/package.json +++ b/package.json @@ -10,17 +10,18 @@ "backend-contract" ], "scripts": { + "prepare": "node scripts/apply-patches.mjs", "dev": "cross-env VITE_SHOW_SAMPLE_DATA=true vite", "preview": "vite preview", "serve": "npm run dev", - "prebuild": "patch-package", + "prebuild": "patch-package --error-on-fail", "contract:generate": "tsx backend-contract/scripts/generate-json-schema.ts && tsx backend-contract/scripts/generate-openapi.ts", "build": "vue-tsc --noEmit && vite build", "build:analyze": "cross-env ANALYZE_BUNDLE=1 npm run build", "test": "vitest", - "test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && wdio run ./wdio.chrome.conf.ts", - "test:e2e:chrome:skip-build": "wdio run ./wdio.chrome.conf.ts", - "test:e2e:dev": "cross-env VITE_SHOW_SAMPLE_DATA=true concurrently -P \"npm run dev\" \"wdio run ./wdio.dev.conf.ts --watch {@}\"", + "test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts", + "test:e2e:chrome:skip-build": "cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts", + "test:e2e:dev": "cross-env HEADED=0 VITE_SHOW_SAMPLE_DATA=true concurrently -P \"npm run dev\" \"wdio run ./wdio.dev.conf.ts --watch {@}\"", "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\" \"scripts/**/*.mjs\"", "build:all": "npm run build:dicom && npm run build:resample && npm run build", "build:dicom": "itk-wasm -s src/io/itk-dicom/ build ", @@ -78,6 +79,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^10.6.2", "fast-deep-equal": "^3.1.3", + "fflate": "0.7.5", "file-saver": "^2.0.5", "gl-matrix": "3.4.3", "globals": "^16.2.0", diff --git a/patches/@kitware+vtk.js+38.0.4.patch b/patches/@kitware+vtk.js+38.0.4.patch index a5f898acc..c352c5755 100644 --- a/patches/@kitware+vtk.js+38.0.4.patch +++ b/patches/@kitware+vtk.js+38.0.4.patch @@ -1,8 +1,108 @@ +diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js +--- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js ++++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js +@@ -574,7 +574,7 @@ + let needRebuild = false; + if (!model.currentRenderPass && model.lastRenderPassShaderReplacement || model.currentRenderPass && model.currentRenderPass.getShaderReplacement() !== model.lastRenderPassShaderReplacement) needRebuild = true; + const numValidInputs = model.currentValidInputs?.length ?? 0; +- if (needRebuild || model.lastHaveSeenDepthRequest !== model.haveSeenDepthRequest || model.lastNumberOfComponents !== model.numberOfComponents || model.lastMultiTexturePerVolumeEnabled !== model.multiTexturePerVolumeEnabled || cellBO.getProgram()?.getHandle() === 0 || model.lastIndependentComponents !== iComp || model.lastUseLabelOutline !== useLabelOutline || model.lastNumValidInputs !== numValidInputs || model.lastSlabThickness !== slabTh || model.lastSlabType !== slabType || model.lastSlabTrapezoidIntegration !== slabTrap) { ++ if (needRebuild || model.lastHaveSeenDepthRequest !== model.haveSeenDepthRequest || model.lastNumberOfComponents !== model.numberOfComponents || model.lastMultiTexturePerVolumeEnabled !== model.multiTexturePerVolumeEnabled || cellBO.getProgram()?.getHandle() === 0 || model.lastIndependentComponents !== iComp || model.lastUseLabelOutline !== useLabelOutline || model.lastNumValidInputs !== numValidInputs || (model.lastSlabThickness > 0) !== (slabTh > 0) || model.lastSlabType !== slabType || model.lastSlabTrapezoidIntegration !== slabTrap) { + model.lastHaveSeenDepthRequest = model.haveSeenDepthRequest; + model.lastNumberOfComponents = model.numberOfComponents; + model.lastMultiTexturePerVolumeEnabled = model.multiTexturePerVolumeEnabled; +@@ -724,33 +724,25 @@ + } + function getSlabSampleLoopLines() { + return [ +- "// Get the first and last samples", ++ "// The center sample is already included in tvalue.", + "int numSlices = 1;", +- "float scaling = min(min(spacing.x, spacing.y), spacing.z) * 0.5;", ++ "float scaling = min(min(abs(spacing.x), abs(spacing.y)), abs(spacing.z)) * 0.5;", ++ "float halfThickness = slabThickness * 0.5;", ++ "// Independently rounded thickness and spacing can differ at an endpoint.", ++ "float boundaryTolerance = halfThickness * 4.76837158203125e-7;", + "vec3 slabNormal = normalize(normalWCVSOutput);", +- "vec3 normalxspacing = scaling * slabNormal;", +- "float distTraveled = length(normalxspacing);", +- "int trapezoid = 0;", +- "// Each march direction leaves the volume for good once it exits the", +- "// unit cube (the sample positions are monotonic along a line and the", +- "// cube is convex), so the loop can stop as soon as both directions", +- "// have exited. Slab thicknesses larger than the volume then only cost", +- "// the in-volume portion of the march.", ++ "int stepIndex = 1;", ++ "float distTraveled = min(scaling, halfThickness);", ++ "// A ray cannot reenter the convex volume after leaving it.", + "bool negExited = false;", + "bool posExited = false;", +- "while (distTraveled < slabThickness * 0.5)", ++ "while (distTraveled > 0.0 && distTraveled <= halfThickness)", + "{", +- " distTraveled += length(normalxspacing);", +- " float fnumSlices = float(numSlices);", +- " bool atSlabBoundary = false;", +- " if (distTraveled > slabThickness * 0.5)", +- " {", +- " // Before stepping outside the slab, sample at the boundaries", +- " normalxspacing = slabNormal * slabThickness * 0.5 / fnumSlices;", +- " trapezoid = slabTrapezoid;", +- " atSlabBoundary = true;", +- " }", +- " vec3 worldPosNeg = vertexWCVSOutput.xyz - fnumSlices * normalxspacing * vboScaling;", ++ " bool atSlabBoundary = halfThickness - distTraveled <= boundaryTolerance;", ++ " float sampleDistance = atSlabBoundary ? halfThickness : distTraveled;", ++ " int trapezoid = atSlabBoundary ? slabTrapezoid : 0;", ++ " vec3 slabOffset = sampleDistance * slabNormal * vboScaling;", ++ " vec3 worldPosNeg = vertexWCVSOutput.xyz - slabOffset;", + " vec3 fragTCoordNeg = (WCTCMatrix0 * vec4(worldPosNeg, 1.0)).xyz;", + " if (!any(greaterThan(fragTCoordNeg, vec3(1.0))) && !any(lessThan(fragTCoordNeg, vec3(0.0))))", + " {", +@@ -758,11 +750,11 @@ + " tvalue = compositeValue(tvalue, newVal, trapezoid);", + " numSlices += 1;", + " }", +- " else if (!atSlabBoundary)", ++ " else", + " {", + " negExited = true;", + " }", +- " vec3 worldPosPos = vertexWCVSOutput.xyz + fnumSlices * normalxspacing * vboScaling;", ++ " vec3 worldPosPos = vertexWCVSOutput.xyz + slabOffset;", + " vec3 fragTCoordPos = (WCTCMatrix0 * vec4(worldPosPos, 1.0)).xyz;", + " if (!any(greaterThan(fragTCoordPos, vec3(1.0))) && !any(lessThan(fragTCoordPos, vec3(0.0))))", + " {", +@@ -770,11 +762,15 @@ + " tvalue = compositeValue(tvalue, newVal, trapezoid);", + " numSlices += 1;", + " }", +- " else if (!atSlabBoundary)", ++ " else", + " {", + " posExited = true;", + " }", +- " if (negExited && posExited) { break; }", ++ " if (atSlabBoundary || (negExited && posExited)) { break; }", ++ " stepIndex += 1;", ++ " float nextDistance = min(float(stepIndex) * scaling, halfThickness);", ++ " if (nextDistance <= distTraveled) { break; }", ++ " distTraveled = nextDistance;", + "}", + "// Finally, if slab type is *mean*, divide the sum by the numSlices", + "if (slabType == 2)", +@@ -1155,7 +1151,7 @@ + let FSSource = shaders.Fragment; + const slabThickness = model.renderable.getSlabThickness(); + let posVCVSDec = ["attribute vec4 vertexWC;", "varying vec4 vertexWCVSOutput;"]; +- posVCVSDec = posVCVSDec.concat([`//${publicAPI.getMTime()}${model.resliceGeomUpdateString}`]); ++ posVCVSDec = posVCVSDec.concat([`//${publicAPI.getMTime()}`]); + if (slabThickness > 0) posVCVSDec = posVCVSDec.concat(["attribute vec3 normalWC;", "varying vec3 normalWCVSOutput;"]); + VSSource = ShaderProgram_default.substitute(VSSource, "//VTK::PositionVC::Dec", posVCVSDec).result; + let posVCVSImpl = ["gl_Position = MCPCMatrix * vertexWC;", "vertexWCVSOutput = vertexWC;"]; diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js -index ba92841..a59d269 100644 --- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js +++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js -@@ -87,7 +87,7 @@ function vtkOpenGLRenderer(publicAPI, model) { +@@ -87,7 +87,7 @@ publicAPI.clear = () => { let clearMask = 0; const gl = model.context; @@ -11,3 +111,15 @@ index ba92841..a59d269 100644 const background = model.renderable.getBackgroundByReference(); gl.clearColor(background[0], background[1], background[2], background[3]); clearMask |= gl.COLOR_BUFFER_BIT; +diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js +--- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js ++++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js +@@ -868,7 +868,7 @@ + const reBuildTex = !tex?.oglObject?.getHandle() || tex?.hash !== scalarsHash; + const updatedExtents = volumeProperty.getUpdatedExtents(); + const hasUpdatedExtents = !!updatedExtents.length; +- if (reBuildTex && !hasUpdatedExtents) { ++ if (reBuildTex && (!hasUpdatedExtents || !tex?.oglObject?.getHandle())) { + const newScalarTexture = Texture_default.newInstance(); + newScalarTexture.setOpenGLRenderWindow(model._openGLRenderWindow); + const dims = imageData.getDimensions(); diff --git a/scripts/apply-patches.mjs b/scripts/apply-patches.mjs new file mode 100644 index 000000000..ce6e52625 --- /dev/null +++ b/scripts/apply-patches.mjs @@ -0,0 +1,12 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +if (process.env.NODE_ENV !== 'production') { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.resolve('patch-package')), '--error-on-fail'], + { stdio: 'inherit' } + ); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} diff --git a/scripts/samples/convert_jhu_oct.py b/scripts/samples/convert_jhu_oct.py new file mode 100644 index 000000000..1df102ded --- /dev/null +++ b/scripts/samples/convert_jhu_oct.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Convert the JHU hc01 OCT image and manual boundaries into a data-only ZIP. + +Requires NumPy and SciPy. Supply the original .vol and .mat files from the +archive identified in jhu-oct-provenance.json. Outputs contain local scan +coordinates and no patient header fields. The optional validity image stays +outside the ZIP, which contains only the image and named segmentation. +""" + +import argparse +import gzip +import hashlib +import json +from pathlib import Path +import struct +import zipfile + +import numpy as np +from scipy.io import loadmat + + +NAMES = ["Retinal nerve fiber layer (RNFL)", "Ganglion cell and inner plexiform layers (GCL+IPL)", + "Inner nuclear layer (INL)", "Outer plexiform layer (OPL)", "Outer nuclear layer (ONL)", + "Photoreceptor inner segments (IS)", "Photoreceptor outer segments (OS)", + "Retinal pigment epithelium (RPE)"] +COLORS = [(230, 70, 70), (245, 160, 40), (225, 210, 50), (85, 190, 90), + (55, 185, 210), (80, 115, 225), (165, 90, 210), (235, 105, 180)] +COLUMNS = [0, 1, 3, 4, 5, 6, 7, 8, 9] + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def interpolate_controls(controls, width): + """Use the source reader's natural cubic in unit parameter intervals.""" + controls = controls[np.argsort(controls[:, 0], kind="stable")] + x, indices = np.unique(controls[:, 0], return_index=True) + y = controls[indices, 1] + if len(x) < 2 or not np.all(x == np.floor(x)): + raise ValueError("Expected at least two integer lateral control positions") + n = len(y) - 1 + gamma, delta, derivative = (np.zeros(n + 1) for _ in range(3)) + gamma[0] = 0.5 + for i in range(1, n): + gamma[i] = 1 / (4 - gamma[i - 1]) + gamma[-1] = 1 / (2 - gamma[-2]) + delta[0] = 3 * (y[1] - y[0]) * gamma[0] + for i in range(1, n): + delta[i] = (3 * (y[i + 1] - y[i - 1]) - delta[i - 1]) * gamma[i] + delta[-1] = (3 * (y[-1] - y[-2]) - delta[-2]) * gamma[-1] + derivative[-1] = delta[-1] + for i in range(n - 1, -1, -1): + derivative[i] = delta[i] - gamma[i] * derivative[i + 1] + values = np.zeros(int(x[-1] - x[0] + 1)) + for i in range(n): + left, right = int(x[i] - x[0]), int(x[i + 1] - x[0]) + t = np.arange(1, right - left) / (right - left) + c2 = 3 * (y[i + 1] - y[i]) - 2 * derivative[i] - derivative[i + 1] + c3 = -2 * (y[i + 1] - y[i]) + derivative[i] + derivative[i + 1] + values[left] = y[i] + values[left + 1:right] = y[i] + derivative[i] * t + c2 * t * t + c3 * t * t * t + values[-1] = y[-1] + return values[np.clip(np.arange(1, width + 1) - int(x[0]), 0, len(values) - 1)] + + +def read_sources(volume_path, annotation_path): + with volume_path.open("rb") as source: + header = source.read(2048) + width, frames, depth = struct.unpack_from("= 0) & (raw <= 1) + native_invalid += int((~good).sum()) + image[frame] = np.sqrt(np.sqrt(np.where(good[:, ::2], raw[:, ::2], 0))) + valid[frame] = good[:, ::2] + controls = loadmat(annotation_path, variable_names=["control_pts"])["control_pts"] + if controls.shape != (frames, 11): + raise ValueError("Expected 49 frames with eleven annotation slots") + boundaries = np.zeros((frames, width, 9)) + crossing_clamps = 0 + for frame in range(frames): + for boundary, column in enumerate(COLUMNS): + curve = interpolate_controls(controls[frame, column], width) + if boundary: + crossing_clamps += int((curve < boundaries[frame, :, boundary - 1]).sum()) + curve = np.maximum(curve, boundaries[frame, :, boundary - 1]) + boundaries[frame, :, boundary] = curve + return image, valid, boundaries, np.array(coordinates), [lateral, axial], native_invalid, crossing_clamps + + +def regularize(image, valid, boundaries, coordinates, spacing): + frames, depth, width = image.shape + native_x = np.arange(0, width * 2, 2) + fraction = native_x / (width * 2 - 1) + slow = coordinates[0, 1] - (coordinates[:, 1, None] + fraction * (coordinates[:, 3] - coordinates[:, 1])[:, None]) + if not np.all(np.diff(slow, axis=0) > 0): + raise ValueError("Source B-scans must have increasing slow-axis positions") + target = np.linspace(slow[0, 0], slow[-1, 0], frames) + lower = np.empty((frames, width), np.int32) + upper = np.empty_like(lower) + weight = np.empty((frames, width)) + physical = np.empty((frames, width), bool) + regular_boundaries = np.empty((frames, width, 9)) + for x in range(width): + positions = slow[:, x] + physical[:, x] = (target >= positions[0]) & (target <= positions[-1]) + lo = np.clip(np.searchsorted(positions, target, side="right") - 1, 0, frames - 1) + hi = np.minimum(lo + 1, frames - 1) + t = np.divide(target - positions[lo], positions[hi] - positions[lo], out=np.zeros(frames), where=hi != lo) + t = np.clip(t, 0, 1) + t[np.abs(t) < 1e-10] = 0 + t[np.abs(1 - t) < 1e-10] = 1 + lower[:, x], upper[:, x], weight[:, x] = lo, hi, t + regular_boundaries[:, x] = (1 - t[:, None]) * boundaries[lo, native_x[x]] + t[:, None] * boundaries[hi, native_x[x]] + output = np.empty_like(image) + validity = np.empty_like(image, dtype=np.uint8) + labels = np.zeros_like(image, dtype=np.uint8) + complete = np.empty((frames, width), bool) + for frame in range(frames): + x = np.arange(width) + lo, hi, t = lower[frame], upper[frame], weight[frame] + good = ((t[:, None] == 1) | valid[lo, :, x]) & ((t[:, None] == 0) | valid[hi, :, x]) & physical[frame, :, None] + values = (1 - t[:, None]) * image[lo, :, x].astype(np.float64) + t[:, None] * image[hi, :, x].astype(np.float64) + output[frame] = np.where(good, values, 0).T + validity[frame] = good.T + rounded = np.floor(regular_boundaries[frame] + 0.5).astype(np.int32) + if rounded.min() < 0 or rounded.max() > depth or np.any(np.diff(rounded, axis=1) < 0): + raise ValueError("Invalid rounded retinal boundary ordering") + layer = np.zeros((depth, width), np.uint8) + rows = np.arange(depth)[:, None] + # The author assigns one-based rows upper+1 through lower inclusive. + for segment in range(8): + layer[(rows >= rounded[:, segment]) & (rows < rounded[:, segment + 1])] = segment + 1 + complete[frame] = ~np.any((layer > 0) & ~good.T, axis=0) & physical[frame] + labels[frame] = np.where(complete[frame][None, :], layer, 0) + return output, labels, validity, [spacing[0] * 2, spacing[1], float(target[1] - target[0])], complete + + +def write_nrrd(path, data, spacing, provenance, metadata): + attribution = provenance["citation"] + lines = ["NRRD0005", "# Local scan coordinates; anatomical patient orientation is not inferred.", + "type: " + ("float" if data.dtype == np.float32 else "unsigned char"), + "dimension: 3", "space dimension: 3", "sizes: " + " ".join(map(str, data.shape[::-1])), + f"space directions: ({spacing[0]:.17g},0,0) (0,{spacing[1]:.17g},0) (0,0,{spacing[2]:.17g})", + "kinds: domain domain domain", "endian: little", "encoding: gzip", + 'space units: "mm" "mm" "mm"', "space origin: (0,0,0)", + "# Source: " + provenance["archive_url"], "# Paper: " + provenance["paper_url"], + "# Attribution: " + attribution, + *["# SHA256 " + source["filename"] + " " + source["sha256"] for source in provenance["sources"]], + "OCT_AxisMeaning:=lateral depth slow", "OCT_DepthAxis:=1", + "OCT_Source:=JHU IACL hc01 manually delineated healthy-control OCT", + *[key + ":=" + value for key, value in metadata.items()]] + path.write_bytes(("\n".join(lines) + "\n\n").encode("ascii") + gzip.compress(data.tobytes(order="C"), mtime=0)) + return {"filename": path.name, "bytes": path.stat().st_size, "sha256": sha256(path)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path, help="Directory containing the original hc01 .vol and .mat") + parser.add_argument("output", type=Path, help="Output directory outside the checkout or in an ignored directory") + parser.add_argument("--write-validity", action="store_true", help="Write an additional validity NRRD outside the ZIP") + args = parser.parse_args() + provenance = json.loads(Path(__file__).with_name("jhu-oct-provenance.json").read_text()) + inputs = [args.source / entry["filename"] for entry in provenance["sources"]] + for path, expected in zip(inputs, provenance["sources"]): + if sha256(path) != expected["sha256"]: + raise ValueError(f"Unexpected source checksum: {path.name}") + *grid, native_invalid, crossing_clamps = read_sources(*inputs) + image, labels, validity, spacing, complete = regularize(*grid) + excluded, total = int((~complete).sum()), int(complete.size) + coverage = f"{excluded} of {total} columns excluded" + if not np.all(np.isfinite(image)) or image.min() < 0 or image.max() > 1: + raise ValueError("Converted image must contain finite reflectivity display values from zero to one") + if np.any(labels[validity == 0]) or set(np.unique(labels)) != set(range(9)): + raise ValueError("Expected eight labeled layers entirely within valid reflectivity") + image = np.rint(image.astype(np.float64) * 255).astype(np.uint8) + args.output.mkdir(parents=True, exist_ok=True) + metadata = {"Segmentation_MasterRepresentation": "Binary labelmap", "Segmentation_ContainedRepresentationNames": "Binary labelmap|", + "Segmentation_ReferenceImageExtentOffset": "0 0 0", + "OCT_CoveragePolicy": f"All eight labels excluded for a column if any expected retinal voxel is invalid; {coverage}"} + width, depth, frames = image.shape[::-1] + for index, (name, color) in enumerate(zip(NAMES, COLORS)): + fields = {"ID": f"Segment_{index + 1}", "Name": name, "LabelValue": str(index + 1), + "Color": " ".join(f"{channel / 255:.6f}" for channel in color), "Layer": "0", + "Extent": f"0 {width - 1} 0 {depth - 1} 0 {frames - 1}", "Tags": "|"} + metadata.update({f"Segment{index}_{key}": value for key, value in fields.items()}) + files = [write_nrrd(args.output / "jhu-hc01.nrrd", image, spacing, provenance, + {"OCT_IntensityTransform": "Nested float32 sqrt(sqrt(valid native reflectivity)) before slow-axis interpolation", + "OCT_InvalidPadding": "Missing reflectivity is zero; genuine zero and missing samples are indistinguishable without the optional validity sidecar", + "OCT_RetinalCoverage": f"{coverage} from all labels because expected retina has missing reflectivity", + "OCT_IntensityEncoding": "uint8; normalized intensity = stored value / 255; nearest rounding after slow-axis interpolation"}), + write_nrrd(args.output / "jhu-hc01.seg.nrrd", labels, spacing, provenance, metadata)] + archive = args.output / "jhu-retinal-layers.zip" + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle: + for entry in files: + info = zipfile.ZipInfo(entry["filename"], (2026, 10, 18, 0, 0, 0)) + info.create_system = 3 # Preserve reference Unix metadata across platforms. + info.external_attr = 0o644 << 16 + bundle.writestr(info, (args.output / entry["filename"]).read_bytes()) + if args.write_validity: + write_nrrd(args.output / "jhu-hc01-validity.nrrd", validity, spacing, provenance, + {"OCT_Validity": "One iff every source with nonzero interpolation weight is valid"}) + report = {"dimensions": list(image.shape[::-1]), "spacing_mm": spacing, "axial_spacing_microns": spacing[1] * 1000, + "intensity_type": "uint8", "normalized_intensity_scale": 255, + "native_invalid_voxels": native_invalid, "surface_crossing_clamps": crossing_clamps, + "complete_retinal_columns": int(complete.sum()), "excluded_retinal_columns": excluded, + "total_retinal_columns": total, "files": files, + "archive": {"filename": archive.name, "bytes": archive.stat().st_size, "sha256": sha256(archive)}} + (args.output / "jhu-hc01-conversion.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/samples/jhu-oct-provenance.json b/scripts/samples/jhu-oct-provenance.json new file mode 100644 index 000000000..873b51183 --- /dev/null +++ b/scripts/samples/jhu-oct-provenance.json @@ -0,0 +1,160 @@ +{ + "dataset": "JHU IACL full retinal layer dataset, healthy control hc01, right eye", + "citation": "Y. He, A. Carass, S.D. Solomon, S. Saidha, P.A. Calabresi, J.L. Prince. Retinal layer parcellation of optical coherence tomography images: Data resource for Multiple Sclerosis and Healthy Controls. Data in Brief 22 (2019), 601-604. DOI: 10.1016/j.dib.2018.12.073.", + "paper_url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC6327073/", + "dataset_url": "https://iacl.ece.jhu.edu/index.php/OCT_Data", + "archive_url": "https://iacl.ece.jhu.edu/~aaron/data/OCT_Manual_Delineations-2018_June_29_b.zip", + "sources": [ + { + "filename": "hc01_spectralis_macula_v1_s1_R.vol", + "archive_member": "OCT_Manual_Delineations-2018_June_29/vol/hc01_spectralis_macula_v1_s1_R.vol", + "bytes": 104921220, + "sha256": "b3cc8ee76292e9c85ba5032ba864c0a3418b831817d458d231e01a453cae3a56" + }, + { + "filename": "hc01_spectralis_macula_v1_s1_R.mat", + "archive_member": "OCT_Manual_Delineations-2018_June_29/delineation/hc01_spectralis_macula_v1_s1_R.mat", + "bytes": 76661, + "sha256": "89b390fe2c0ec59feabefe611fd411640917c96521d21650b22a85e8786fa85b" + } + ], + "annotation_reference": { + "repository": "https://github.com/heyufan1995/oct_preprocess/tree/9248a91ed093711fe2642da491bae6faa58b8e9d", + "reader": "OCTMatTool/Preprocess/Preprocess Utils/readControlPoints.m", + "labeling": "OCTMatTool/Preprocess/Preprocess Utils/convertBoundariesToLabels.m", + "selection": "OCTMatTool/Preprocess/Preprocess.m removes empty one-based boundary slots 3 and 11", + "interpolation": "Sort and deduplicate lateral controls, source unit-parameter natural cubic spline, nearest endpoint extrapolation, and clamp successive boundaries against crossing.", + "rasterization": "Round one-based depth boundaries; each layer occupies upper+1 through lower inclusive.", + "coverage": "All 49 B-scans contain nine manually delineated boundaries. The eight layers are inferred between adjacent source boundaries." + }, + "layers": [ + { + "value": 1, + "name": "RNFL", + "description": "Retinal nerve fiber layer" + }, + { + "value": 2, + "name": "GCL+IPL", + "description": "Ganglion cell and inner plexiform layers" + }, + { + "value": 3, + "name": "INL", + "description": "Inner nuclear layer" + }, + { + "value": 4, + "name": "OPL", + "description": "Outer plexiform layer" + }, + { + "value": 5, + "name": "ONL", + "description": "Outer nuclear layer" + }, + { + "value": 6, + "name": "IS", + "description": "Photoreceptor inner segments" + }, + { + "value": 7, + "name": "OS", + "description": "Photoreceptor outer segments" + }, + { + "value": 8, + "name": "RPE", + "description": "Retinal pigment epithelium" + } + ], + "conversion": { + "command": "python scripts/samples/convert_jhu_oct.py /path/to/extracted/source .tmp/jhu-oct --write-validity", + "dependencies": [ + "numpy", + "scipy" + ], + "source_dimensions_lateral_depth_slow": [ + 1024, + 496, + 49 + ], + "output_dimensions_lateral_depth_slow": [ + 512, + 496, + 49 + ], + "output_spacing_mm": [ + 0.012018056586384773, + 0.003867251332849264, + 0.12810914486180988 + ], + "axial_spacing_microns": 3.867251332849264, + "lateral_sampling": "Retain native lateral indices 0,2,...,1022; native index 1023 is omitted. ScaleX is doubled.", + "slow_sampling": "Linearly resample transformed intensities and unrounded boundaries from per-B-scan physical positions to 49 evenly spaced positions across 6.149238953366875 mm. Native adjacent pitches range from 0.12010203066263614 to 0.13211284885730468 mm.", + "axial_sampling": "Native depth samples and ScaleZ are preserved at 3.867251332849264 microns per voxel.", + "coordinates": "Shared local zero origin, identity direction, and millimetre spacing for image and segmentation. Patient anatomical orientation is not inferred.", + "intensities": "Nested float32 square roots of source reflectivity before slow-axis interpolation, then nearest-even rounding of 255 times the regularized values to uint8. Stored values range from 0 to 255; divide by 255 for normalized display intensity. This is a display transform, not unmodified linear reflectivity.", + "missing_reflectivity": "Non-finite or out-of-range native values are missing. A resampled voxel is valid only when every source with nonzero interpolation weight is valid. Missing output intensity is zero.", + "missing_annotations": "An entire retinal column is excluded from all eight labels when any expected retinal voxel lacks valid intensity. 22 of 25088 output columns are excluded; 25066 have complete retinal coverage.", + "limitations": "Without the optional validity image, genuine zero reflectivity and missing intensity are indistinguishable. Uint8 quantization has a maximum normalized error of 1/510 and can turn small valid intensities into zero; discarded intensity precision cannot be recovered. Zero-filled missing samples contribute to mean and sum projections. Missing labels are zero, so the image/segmentation pair alone cannot distinguish anatomical absence from excluded coverage. Thickness is quantized to the native axial voxel pitch and inherits manual-boundary and slow-axis interpolation uncertainty.", + "bundle": "jhu-retinal-layers.zip contains only jhu-hc01.nrrd and jhu-hc01.seg.nrrd. No layout, session, manifest, or runtime configuration. Optional validity and conversion reports remain outside the ZIP.", + "patient_metadata": "No patient or visit header fields are exported.", + "loading": "Load the ZIP from the Retinal OCT layers sample, disk, or URL parameters. The default seg filename convention selects jhu-hc01.nrrd as the base image and associates jhu-hc01.seg.nrrd as its segmentation. Select the En face view and use the default Coronal projection axis for this non-DICOM OCT; physical calibration is already present in the NRRD." + }, + "terms": { + "article_license": "CC BY 4.0 applies to the article.", + "dataset_statement": "The institutional dataset page and archive README request citation of the paper. Redistribution of the raw data and converted derivatives is permitted.", + "distribution": "The converted image and segmentation are publicly downloadable from the hosted derivative archive recorded in reference_outputs.archive." + }, + "reference_outputs": { + "files": [ + { + "filename": "jhu-hc01.nrrd", + "bytes": 8320884, + "sha256": "6f4fdf19c612b52f604f2468361b5fd4249d36dbed0657db97722bbf716dd729", + "voxel_payload_sha256": "6c3df78b23313b4e0292ee1197b177c8b2aefc500492e973592c40ac825114ce" + }, + { + "filename": "jhu-hc01.seg.nrrd", + "bytes": 98980, + "sha256": "22de13e912b3b3dd47cad11bfef76efdc4b74749e45f47e99846e2e11bc74837", + "voxel_payload_sha256": "ea0f7df26604f3f1193321766123242c4165cfb4fb99a32a335e6b789c977edf" + } + ], + "archive": { + "filename": "jhu-retinal-layers.zip", + "download_url": "https://data.kitware.com/api/v1/file/6ae786c91747c43eb290172f/download", + "file_id": "6ae786c91747c43eb290172f", + "content_type": "application/x-zip-compressed", + "bytes": 8420098, + "sha256": "14e075d6b612db0bc6a606803c5394a4b5757debbd0f34f3f995ad5e969e2130" + }, + "regularization_runtime": { + "python": "3.11.13", + "numpy": "2.4.6", + "scipy": "1.17.1" + }, + "encoding_runtime": { + "python": "3.10.11", + "numpy": "2.2.6" + }, + "regularized_float32_input": { + "download_url": "https://data.kitware.com/api/v1/file/6ae4eeea1747c43eb2901727/download", + "sha256": "682dc9a14fbec482f8e4b0ec2da9e52be5aee40e1080b114437ec225726d7b57" + }, + "checksum_note": "NRRD and ZIP hashes identify the reference outputs, verified against the hosted derivative archive. The uint8 reference was encoded from the existing regularized float32 image without rerunning the unchanged source reading and regularization. Voxel-payload hashes avoid differences in gzip implementation across runtimes.", + "segmentation_label_counts_including_background": [ + 10452711, + 317632, + 409992, + 180509, + 238345, + 335378, + 107797, + 163552, + 237732 + ] + } +} diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index a22b4cfaa..f0fa45f68 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -34,6 +34,7 @@ import { useViewStore } from '@/src/store/views'; const BASE_MODALITY_TYPES = { CT: { priority: 3 }, MR: { priority: 3 }, + OPT: { priority: 3 }, US: { priority: 2 }, DX: { priority: 1 }, } as const; @@ -48,7 +49,8 @@ function findBaseDicom(loadableDataSources: Array) { const baseDicomVolumes = dicoms .map((dicomSource) => { const volumeInfo = dicomStore.volumeInfo[dicomSource.dataID]; - const modality = volumeInfo?.Modality as keyof typeof BASE_MODALITY_TYPES; + const modality = + volumeInfo?.Modality?.trim() as keyof typeof BASE_MODALITY_TYPES; if (modality in BASE_MODALITY_TYPES) return { dicomSource, @@ -63,7 +65,7 @@ function findBaseDicom(loadableDataSources: Array) { { priority: a, volumeInfo: infoA }, { priority: b, volumeInfo: infoB } ) => { - const priorityDiff = a - b; + const priorityDiff = b - a; if (priorityDiff !== 0) return priorityDiff; // same modality, then more slices preferred if (!infoA.NumberOfSlices) return 1; diff --git a/src/assets/samples/Retinal-Layers.jpg b/src/assets/samples/Retinal-Layers.jpg new file mode 100644 index 000000000..2f19d43c5 Binary files /dev/null and b/src/assets/samples/Retinal-Layers.jpg differ diff --git a/src/components/AboutBox.vue b/src/components/AboutBox.vue index 151799db5..6764a426e 100644 --- a/src/components/AboutBox.vue +++ b/src/components/AboutBox.vue @@ -168,6 +168,15 @@
Sample data provided by the following sources:
    +
  • + MRA Head and Neck: Test case 98890234 from the "Patient Contributed + Image Repository" (PCIR.org) +
  • PROSTATEx Challenge Data: Geert Litjens, Oscar Debats, Jelle Barentsz, Nico Karssemeijer, and Henkjan Huisman. "ProstateX Challenge data", @@ -179,15 +188,6 @@ >TCIA)
  • -
  • - MRA Head and Neck: Test case 98890234 from the "Patient Contributed - Image Repository" (PCIR.org) -
  • 3D ultrasound of a fetus: Sample data from CC BY 4.0. Ten original DICOM instances selected from each series.
  • +
  • + Retinal layers: OCT data and manual delineations from + Johns Hopkins IACL. Y. He, A. Carass, S. D. Solomon, S. Saidha, P. A. Calabresi, and J. + L. Prince, "Retinal layer parcellation of optical coherence tomography + images: Data resource for Multiple Sclerosis and Healthy Controls", + Data in Brief 22:601–604 (2019), + doi:10.1016/j.dib.2018.12.073. Source data publication: © 2018 The Authors, + CC BY 4.0. Converted from VOL/MAT to NRRD, with lateral subsampling, + interpolation onto a regular B-scan grid, and rasterization of the + manual boundaries into eight tissue labels. +
diff --git a/src/components/BaseImageProperties.vue b/src/components/BaseImageProperties.vue index dc4471295..9cfee04ac 100644 --- a/src/components/BaseImageProperties.vue +++ b/src/components/BaseImageProperties.vue @@ -1,21 +1,24 @@ + + diff --git a/src/components/tools/crosshairs/CrosshairsWidget2D.vue b/src/components/tools/crosshairs/CrosshairsWidget2D.vue index 1849f9d7d..917eb6dde 100644 --- a/src/components/tools/crosshairs/CrosshairsWidget2D.vue +++ b/src/components/tools/crosshairs/CrosshairsWidget2D.vue @@ -1,94 +1,31 @@ - + + diff --git a/src/components/tools/crosshairs/useCrosshairsWidget.ts b/src/components/tools/crosshairs/useCrosshairsWidget.ts new file mode 100644 index 000000000..ecf399266 --- /dev/null +++ b/src/components/tools/crosshairs/useCrosshairsWidget.ts @@ -0,0 +1,51 @@ +import { inject, onMounted, onScopeDispose, unref, type MaybeRef } from 'vue'; +import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; +import { VtkViewContext } from '@/src/components/vtk/context'; +import { onVTKEvent } from '@/src/composables/onVTKEvent'; +import { useCrosshairsToolStore } from '@/src/store/tools/crosshairs'; +import type { vtkCrosshairsViewWidget } from '@/src/vtk/CrosshairsWidget'; + +export function useCrosshairsWidget(viewId: MaybeRef) { + const view = inject(VtkViewContext); + if (!view) throw new Error('No VtkView'); + + const crosshairs = useCrosshairsToolStore(); + const factory = crosshairs.getWidgetFactory(); + const widget = view.widgetManager.addWidget( + factory + ) as vtkCrosshairsViewWidget; + const manipulator = vtkPlaneManipulator.newInstance(); + widget.setManipulator(manipulator); + widget.setVisibility(true); + let dragging = false; + onVTKEvent(widget, 'onStartInteractionEvent', () => { + dragging = true; + }); + onVTKEvent(widget, 'onEndInteractionEvent', () => { + dragging = false; + }); + + // Interaction belongs to the view that picked the shared handle. + const handle = factory.getWidgetState().getHandle(); + onVTKEvent(widget, 'onInteractionEvent', () => { + const origin = handle.getOrigin(); + if (!origin) return; + crosshairs.setPosition(origin, unref(viewId)); + }); + + onMounted(() => view.widgetManager.renderWidgets()); + onScopeDispose(() => { + widget.setEnabled(false); + view.interactor.cancelAnimation(widget, true); + // A physical drag belongs to this view; a keyboard-only hold may continue + // through remount in another view of the shared widget. + if (dragging) { + crosshairs.setDragging(false); + view.renderWindowView.set({ cursor: 'default' }); + } + view.widgetManager.removeWidget(factory); + manipulator.delete(); + }); + + return manipulator; +} diff --git a/src/components/tools/windowing/WindowLevelControls.vue b/src/components/tools/windowing/WindowLevelControls.vue index 19a370a94..dc1669c95 100644 --- a/src/components/tools/windowing/WindowLevelControls.vue +++ b/src/components/tools/windowing/WindowLevelControls.vue @@ -10,8 +10,11 @@ import { WLAutoRanges, WLPresetsCT } from '@/src/constants'; import { getWindowLevels, useDICOMStore } from '@/src/store/datasets-dicom'; import { isDicomImage } from '@/src/utils/dataSelection'; import { storeToRefs } from 'pinia'; +import { useEnFaceViewStore } from '@/src/enface/store'; +import ReasonedAction from '@/src/components/ReasonedAction.vue'; export default defineComponent({ + components: { ReasonedAction }, props: { viewId: String, imageId: String, @@ -22,9 +25,20 @@ export default defineComponent({ const windowingStore = useWindowingStore(); const viewStore = useViewStore(); const dicomStore = useDICOMStore(); + const enFaceStore = useEnFaceViewStore(); const { activeView } = storeToRefs(viewStore); const targetViewID = computed(() => props.viewId ?? activeView.value); const targetImageID = computed(() => props.imageId ?? currentImageID.value); + const isSumProjection = computed(() => { + const viewID = targetViewID.value; + const imageID = targetImageID.value; + return ( + !!viewID && + !!imageID && + viewStore.getView(viewID)?.type === 'EnFace' && + enFaceStore.settingsFor(viewID, imageID).method === 'sum' + ); + }); function parseLabel(text: string) { return text.replace(/([A-Z])/g, ' $1').trim(); @@ -65,44 +79,59 @@ export default defineComponent({ const wlWidth = computed(() => wlConfig.value.width ?? 1); const wlLevel = computed(() => wlConfig.value.level ?? 0.5); - const widthFocused = ref(false); - const levelFocused = ref(false); - - const formatForDisplay = (value: number) => Number(value.toPrecision(4)); - - const displayWidth = computed({ - get: () => - widthFocused.value ? wlWidth.value : formatForDisplay(wlWidth.value), - set: (value: number) => { - const imageID = targetImageID.value; - const viewID = targetViewID.value; - if (!imageID || !viewID || !Number.isFinite(value)) return; - windowingStore.updateConfig( - viewID, - imageID, - { width: value, level: wlLevel.value }, - true - ); - emit('change'); - }, - }); - - const displayLevel = computed({ - get: () => - levelFocused.value ? wlLevel.value : formatForDisplay(wlLevel.value), - set: (value: number) => { - const imageID = targetImageID.value; - const viewID = targetViewID.value; - if (!imageID || !viewID || !Number.isFinite(value)) return; - windowingStore.updateConfig( - viewID, - imageID, - { width: wlWidth.value, level: value }, - true - ); - emit('change'); - }, - }); + const manualField = (field: 'width' | 'level') => { + const currentValue = computed(() => + field === 'width' ? wlWidth.value : wlLevel.value + ); + const editing = ref(false); + // Preserve incomplete decimal and exponent entries until the field loses focus. + const draft = ref(''); + const value = computed({ + get: () => + editing.value + ? draft.value + : String(Number(currentValue.value.toPrecision(4))), + set: (text: string) => { + draft.value = text; + const parsed = Number(text); + const imageID = targetImageID.value; + const viewID = targetViewID.value; + if ( + !imageID || + !viewID || + !text.trim() || + !Number.isFinite(parsed) || + (field === 'width' && parsed <= 0) + ) + return; + windowingStore.updateConfig( + viewID, + imageID, + { + width: wlWidth.value, + level: wlLevel.value, + [field]: parsed, + }, + true + ); + emit('change'); + }, + }); + return { + value, + begin: () => { + draft.value = String(currentValue.value); + editing.value = true; + }, + end: () => { + editing.value = false; + }, + }; + }; + const widthField = manualField('width'); + const levelField = manualField('level'); + const displayWidth = widthField.value; + const displayLevel = levelField.value; const wlOptions = computed({ get() { @@ -144,6 +173,14 @@ export default defineComponent({ }, }); + function resetContrast() { + const viewID = targetViewID.value; + const imageID = targetImageID.value; + if (!viewID || !imageID) return; + windowingStore.resetContrast(viewID, imageID); + emit('change'); + } + // --- Tag WL Options --- // const tags = computed(() => { if (targetImageID.value && isDicomImage(targetImageID.value)) { @@ -155,15 +192,27 @@ export default defineComponent({ return { parseLabel, + resetContrast, + resetContrastReason: computed(() => { + if (!targetViewID.value) return 'Select a view to reset contrast.'; + if (!targetImageID.value) + return 'Load an image in this view to reset contrast.'; + return ''; + }), wlOptions, WLPresetsCT, showCtPresets, tags, + sourcePresetReason: computed(() => + isSumProjection.value + ? 'Source intensity presets do not apply to Sum projections. Use Auto Window/Level or Manual instead.' + : '' + ), WLAutoRanges, + widthField, + levelField, displayWidth, displayLevel, - widthFocused, - levelFocused, }; }, }); @@ -173,7 +222,12 @@ export default defineComponent({ - + - + Auto Window/Level
@@ -218,8 +279,9 @@ export default defineComponent({
+
+ + + Reset contrast + + +
diff --git a/src/components/vtk/VtkRenderWindowParent.vue b/src/components/vtk/VtkRenderWindowParent.vue index ea64ef36d..5063b20e2 100644 --- a/src/components/vtk/VtkRenderWindowParent.vue +++ b/src/components/vtk/VtkRenderWindowParent.vue @@ -1,5 +1,6 @@ diff --git a/src/components/vtk/VtkSlabProjectionRepresentation.vue b/src/components/vtk/VtkSlabProjectionRepresentation.vue new file mode 100644 index 000000000..444411c39 --- /dev/null +++ b/src/components/vtk/VtkSlabProjectionRepresentation.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue index aa8a08e7c..2d404de5a 100644 --- a/src/components/vtk/VtkSliceView.vue +++ b/src/components/vtk/VtkSliceView.vue @@ -22,12 +22,17 @@ import { Maybe } from '@/src/types'; import { VtkViewApi } from '@/src/types/vtk-types'; import { VtkViewContext } from '@/src/components/vtk/context'; import { useViewCameraStore } from '@/src/store/view-configs/camera'; +import type { ImageMetadata } from '@/src/types/image'; +import { getLPSDirections } from '@/src/utils/lps'; +import { vec3 } from 'gl-matrix'; type Props = { viewId: string; imageId: Maybe; viewDirection: LPSAxisDir; viewUp: LPSAxisDir; + renderGeometry?: { metadata: ImageMetadata | null; fitKey: string }; + zoomOnScroll?: boolean; }; const props = defineProps(); @@ -42,7 +47,12 @@ const vtkContainerRef = ref(); const viewCameraStore = useViewCameraStore(); const { disableCameraAutoReset } = storeToRefs(viewCameraStore); -const { metadata: imageMetadata } = useImage(imageID); +const { metadata: sourceMetadata } = useImage(imageID); +const imageMetadata = computed(() => + props.renderGeometry === undefined + ? sourceMetadata.value + : props.renderGeometry.metadata +); // use a detached scope so that actors can be removed from // the renderer before the renderer is deleted. @@ -50,7 +60,7 @@ const scope = effectScope(true); const view = scope.run(() => useVtkView(vtkContainerRef, { separateImageLayer: true, - clippingBounds: computed(() => imageMetadata.value.worldBounds), + clippingBounds: computed(() => imageMetadata.value?.worldBounds), }) )!; @@ -87,14 +97,10 @@ const autoFit = useAutoFitState( ); function autoFitImage() { - if (!autoFit.autoFit.value) return; + const metadata = imageMetadata.value; + if (!autoFit.autoFit.value || !metadata) return; autoFit.withPaused(() => { - resizeToFitImage( - view, - imageMetadata.value, - viewDirection.value, - viewUp.value - ); + resizeToFitImage(view, metadata, viewDirection.value, viewUp.value); }); } @@ -104,29 +110,79 @@ useResizeObserver(vtkContainerRef, () => { }); function resetCamera() { + const metadata = imageMetadata.value; + if (!metadata) return; autoFit.autoFit.value = true; autoFit.withPaused(() => { - resetCameraToImage( - view, - imageMetadata.value, - viewDirection.value, - viewUp.value - ); + resetCameraToImage(view, metadata, viewDirection.value, viewUp.value); autoFitImage(); }); + viewCameraStore.markCameraAsInitialized(viewID.value, imageID.value); } +let previousGeometry: { + viewId: string; + imageId: Maybe; + key?: string; + direction: LPSAxisDir; + up: LPSAxisDir; +}; +const isPreviousImage = (viewId: string, imageId: Maybe) => + previousGeometry?.viewId === viewId && previousGeometry.imageId === imageId; +const projectionOrientationChanged = (direction: LPSAxisDir, up: LPSAxisDir) => + !!props.renderGeometry && + (previousGeometry.direction !== direction || previousGeometry.up !== up); watchImmediate( - [viewID, imageID, disableCameraAutoReset], - ([viewID_, imageID_, noAutoReset]) => { + [ + viewID, + imageID, + disableCameraAutoReset, + imageMetadata, + () => props.renderGeometry?.fitKey, + viewDirection, + viewUp, + ], + ([viewID_, imageID_, noAutoReset, metadata, geometryKey, direction, up]) => { + if (!metadata) return; + const sameImage = isPreviousImage(viewID_, imageID_); + const geometryChanged = sameImage && previousGeometry.key !== geometryKey; if ( imageID_ && - !viewCameraStore.isCameraInitialized(viewID_, imageID_) && + (!viewCameraStore.isCameraInitialized(viewID_, imageID_) || + geometryChanged) && !noAutoReset ) { resetCamera(); - viewCameraStore.markCameraAsInitialized(viewID_, imageID_); + } else if ( + noAutoReset && + sameImage && + projectionOrientationChanged(direction, up) + ) { + // A new projection plane still needs a facing camera; keep pan and zoom. + autoFit.withPaused(() => { + const camera = view.renderer.getActiveCamera(); + const focalPoint = [...camera.getFocalPoint()]; + const directions = getLPSDirections(metadata.orientation); + const normal = vec3.normalize([0, 0, 0], directions[direction]); + camera.setPositionAndFocalPoint( + focalPoint.map( + (value, axis) => value - camera.getDistance() * normal[axis] + ), + focalPoint + ); + const vertical = directions[up]; + camera.setViewUp(vertical[0], vertical[1], vertical[2]); + view.renderer.resetCameraClippingRange(metadata.worldBounds); + view.requestRender(); + }); } + previousGeometry = { + viewId: viewID_, + imageId: imageID_, + key: geometryKey, + direction, + up, + }; } ); @@ -137,11 +193,13 @@ watchImmediate( usePersistCameraConfig(viewID, imageID, view.renderer.getActiveCamera()); watchImmediate([imageMetadata, disableCameraAutoReset], () => { + const metadata = imageMetadata.value; + if (!metadata) return; if ( viewCameraStore.isCameraInitialized(viewID.value, imageID.value) || disableCameraAutoReset.value ) { - view.renderer.resetCameraClippingRange(imageMetadata.value.worldBounds); + view.renderer.resetCameraClippingRange(metadata.worldBounds); } }); @@ -162,6 +220,17 @@ function onPointerDown() { function onPointerUp() { autoFit.pause(); } + +function onWheel(event: WheelEvent) { + if ( + props.zoomOnScroll && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + autoFit.autoFit.value = false; + } +}