diff --git a/Examples/Rendering/LabelmapEdgeProjectionReslice/index.js b/Examples/Rendering/LabelmapEdgeProjectionReslice/index.js new file mode 100644 index 00000000000..226a27ca9b8 --- /dev/null +++ b/Examples/Rendering/LabelmapEdgeProjectionReslice/index.js @@ -0,0 +1,273 @@ +import '@kitware/vtk.js/favicon'; + +// Load the rendering pieces we want to use (for both WebGL and WebGPU) +import '@kitware/vtk.js/Rendering/Profiles/All'; + +import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow'; +import vtkHttpDataSetReader from '@kitware/vtk.js/IO/Core/HttpDataSetReader'; +import vtkImageResliceMapper from '@kitware/vtk.js/Rendering/Core/ImageResliceMapper'; +import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; +import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { SlabTypes } from '@kitware/vtk.js/Rendering/Core/ImageResliceMapper/Constants'; +import '@kitware/vtk.js/IO/Core/DataAccessHelper/HttpDataAccessHelper'; +import GUI from 'lil-gui'; + +/** + * This example demonstrates slab-projected label outlines with the image + * reslice mapper. + * + * It is the reslice-mapper counterpart of the LabelmapEdgeProjection example + * (which uses the volume mapper's LABELMAP_EDGE_PROJECTION_BLEND). The base + * image renders as a thick-slab MIP through vtkImageResliceMapper, and the + * labelmap renders as a second reslice actor over the same slab with + * useLabelOutline enabled. + * + * With a slab active, the mapper projects label presence across the whole + * slab and draws each label's outline where its projected footprint ends, so + * segment edges remain visible over the MIP instead of the labelmap either + * disappearing (single-slice labels) or filling the whole projected footprint. + */ +const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance({ + background: [0, 0, 0], +}); +const renderer = fullScreenRenderer.getRenderer(); +const renderWindow = fullScreenRenderer.getRenderWindow(); + +// ---------------------------------------------------------------------------- +// Base image pipeline (thick-slab MIP through the reslice mapper) +// ---------------------------------------------------------------------------- + +const slicePlane = vtkPlane.newInstance(); +slicePlane.setNormal(0, 0, 1); + +const imageActor = vtkImageSlice.newInstance(); +const imageMapper = vtkImageResliceMapper.newInstance(); +imageMapper.setSlicePlane(slicePlane); +imageMapper.setSlabType(SlabTypes.MAX); +imageActor.setMapper(imageMapper); + +const labelmapActor = vtkImageSlice.newInstance(); +const labelmapMapper = vtkImageResliceMapper.newInstance(); +labelmapMapper.setSlicePlane(slicePlane); +labelmapMapper.setSlabType(SlabTypes.MAX); +labelmapActor.setMapper(labelmapMapper); + +// ---------------------------------------------------------------------------- +// GUI controls +// ---------------------------------------------------------------------------- +const gui = new GUI(); +const guiParams = { + slabThickness: 1, + thickness1: 3, + thickness2: 3, + outlineOpacity: 1, +}; + +let maxSlabThickness = 100; +let slabThicknessController = null; + +// ---------------------------------------------------------------------------- +// Common functions +// ---------------------------------------------------------------------------- + +function createSphericalLabel(data, d, c, radius, label) { + for (let k = 0; k < d[2]; k++) { + for (let j = 0; j < d[1]; j++) { + for (let i = 0; i < d[0]; i++) { + const dx = i - c[0]; + const dy = j - c[1]; + const dz = k - c[2]; + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + if (distance <= radius) { + const index = k * d[1] * d[0] + j * d[0] + i; + data[index] = label; + } + } + } + } +} + +function setupImageTransferFunctions() { + const ctfun = vtkColorTransferFunction.newInstance(); + ctfun.addRGBPoint(0, 0, 0, 0); + ctfun.addRGBPoint(255, 1.0, 1.0, 1.0); + + const ofun = vtkPiecewiseFunction.newInstance(); + ofun.addPoint(0.0, 1.0); + ofun.addPoint(255.0, 1.0); + + imageActor.getProperty().setRGBTransferFunction(0, ctfun); + imageActor.getProperty().setPiecewiseFunction(0, ofun); + imageActor.getProperty().setUseLookupTableScalarRange(true); +} + +function createLabelmap(imgData, d, c, r1, r2) { + const values = new Uint8Array(imgData.getNumberOfPoints()); + const labelMapData = vtkImageData.newInstance( + imgData.get('spacing', 'origin', 'direction') + ); + + const dataArray = vtkDataArray.newInstance({ + numberOfComponents: 1, // labelmap with single component + values, + }); + labelMapData.getPointData().setScalars(dataArray); + + labelMapData.setDimensions(...imgData.getDimensions()); + labelMapData.setSpacing(...imgData.getSpacing()); + labelMapData.setOrigin(...imgData.getOrigin()); + labelMapData.setDirection(...imgData.getDirection()); + + const labelmapArray = labelMapData.getPointData().getScalars().getData(); + + // Same labels as the LabelmapEdgeProjection example + createSphericalLabel(labelmapArray, d, c, r1, 1); + createSphericalLabel( + labelmapArray, + d, + [c[0] + 2 * r1, c[1] + 2 * r1, c[2] + 2 * r1], + r2, + 2 + ); + + return labelMapData; +} + +function setupLabelmapTransferFunctions() { + const labelMapColorFunction = vtkColorTransferFunction.newInstance(); + labelMapColorFunction.addRGBPoint(0, 0, 0, 0); + labelMapColorFunction.addRGBPoint(1, 0, 0, 1); // Blue for label 1 + labelMapColorFunction.addRGBPoint(2, 1, 0, 0); // Red for label 2 + + // Edge projection only: the label fill stays fully transparent + const labelMapOpacityFunction = vtkPiecewiseFunction.newInstance(); + labelMapOpacityFunction.addPoint(0, 0); + labelMapOpacityFunction.addPoint(1, 0); + labelMapOpacityFunction.addPoint(2, 0); + + const property = labelmapActor.getProperty(); + property.setRGBTransferFunction(0, labelMapColorFunction); + property.setPiecewiseFunction(0, labelMapOpacityFunction); + property.setUseLookupTableScalarRange(true); + + // set interpolation to nearest to avoid blending labels + property.setInterpolationTypeToNearest(); + + property.setUseLabelOutline(true); + property.setLabelOutlineOpacity(guiParams.outlineOpacity); + property.setLabelOutlineThickness([ + guiParams.thickness1, + guiParams.thickness2, + ]); +} + +function updateOutlineThickness() { + labelmapActor + .getProperty() + .setLabelOutlineThickness([guiParams.thickness1, guiParams.thickness2]); + renderWindow.render(); +} + +function updateOutlineOpacity() { + labelmapActor.getProperty().setLabelOutlineOpacity(guiParams.outlineOpacity); + renderWindow.render(); +} + +function updateSlabThickness() { + imageMapper.setSlabThickness(guiParams.slabThickness); + labelmapMapper.setSlabThickness(guiParams.slabThickness); + renderWindow.render(); +} + +gui + .add(guiParams, 'thickness1', 1, 10, 1) + .name('Segment 1 Thickness') + .onChange(updateOutlineThickness); +gui + .add(guiParams, 'thickness2', 1, 10, 1) + .name('Segment 2 Thickness') + .onChange(updateOutlineThickness); +gui + .add(guiParams, 'outlineOpacity', 0, 1, 0.05) + .name('Outline Opacity') + .onChange(updateOutlineOpacity); + +// ---------------------------------------------------------------------------- +// Main execution +// ---------------------------------------------------------------------------- + +const reader = vtkHttpDataSetReader.newInstance({ fetchGzip: true }); + +reader.setUrl(`${__BASE_PATH__}/data/volume/LIDC2.vti`).then(() => { + reader.loadData().then(() => { + const imageData = reader.getOutputData(); + + const dims = imageData.getDimensions(); + const center = dims.map((d) => Math.floor(d / 2)); + const minDim = Math.min(...dims); + const radius1 = Math.floor(minDim / 6); + const radius2 = Math.floor(minDim / 6); + + const labelMapData = createLabelmap( + imageData, + dims, + center, + radius1, + radius2 + ); + + imageMapper.setInputData(imageData); + labelmapMapper.setInputData(labelMapData); + + setupImageTransferFunctions(); + setupLabelmapTransferFunctions(); + + const worldCenter = imageData.getCenter(); + slicePlane.setOrigin(worldCenter); + + // Default the slab to the full volume extent along the slice normal so + // the base image renders as a full MIP like the volume-mapper example + const bounds = imageData.getBounds(); + maxSlabThickness = Math.ceil(bounds[5] - bounds[4]); + guiParams.slabThickness = maxSlabThickness; + slabThicknessController = gui + .add(guiParams, 'slabThickness', 0, maxSlabThickness, 1) + .name('Slab Thickness') + .onChange(updateSlabThickness); + slabThicknessController.updateDisplay(); + updateSlabThickness(); + + renderer.addActor(imageActor); + renderer.addActor(labelmapActor); + + // Keep the reslice plane facing the camera so rotating with the default + // trackball style spins the slab MIP like the volume-mapper example + const camera = renderer.getActiveCamera(); + camera.onModified(() => { + slicePlane.setNormal(camera.getDirectionOfProjection()); + slicePlane.setOrigin(worldCenter); + }); + + renderer.resetCamera(); + renderer.resetCameraClippingRange(); + renderWindow.render(); + }); +}); + +// ---------------------------------------------------------------------------- +// Make some variables global so that you can inspect and +// modify objects in your browser's developer console: +// ---------------------------------------------------------------------------- + +global.source = reader; +global.imageMapper = imageMapper; +global.imageActor = imageActor; +global.labelmapMapper = labelmapMapper; +global.labelmapActor = labelmapActor; +global.renderer = renderer; +global.renderWindow = renderWindow; diff --git a/Sources/Rendering/Core/ImageResliceMapper/test/testImageResliceMapperLabelOutlineSlab.js b/Sources/Rendering/Core/ImageResliceMapper/test/testImageResliceMapperLabelOutlineSlab.js new file mode 100644 index 00000000000..48c5582c040 --- /dev/null +++ b/Sources/Rendering/Core/ImageResliceMapper/test/testImageResliceMapperLabelOutlineSlab.js @@ -0,0 +1,184 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; + +import 'vtk.js/Sources/Rendering/OpenGL/Profiles/All'; + +import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData'; +import vtkImageProperty from 'vtk.js/Sources/Rendering/Core/ImageProperty'; +import vtkImageResliceMapper from 'vtk.js/Sources/Rendering/Core/ImageResliceMapper'; +import vtkImageSlice from 'vtk.js/Sources/Rendering/Core/ImageSlice'; +import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction'; +import vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; +import { SlabTypes } from 'vtk.js/Sources/Rendering/Core/ImageResliceMapper/Constants'; + +import baseline from './testImageResliceMapperLabelOutlineSlab.png'; + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'Test ImageResliceMapper LabelOutline with slab projection', + async () => { + const gc = testUtils.createGarbageCollector(); + expect( + 'rendering', + 'vtkImageResliceMapper testImageResliceMapperLabelOutlineSlab' + ).toBeTruthy(); + + const bodyElem = document.querySelector('body'); + const container = gc.registerDOMElement(document.createElement('div')); + bodyElem.appendChild(container); + + const renderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + const glwindow = gc.registerResource(renderWindow.newAPISpecificView()); + glwindow.setContainer(container); + renderWindow.addView(glwindow); + glwindow.setSize(400, 400); + + const renderer = gc.registerResource(vtkRenderer.newInstance()); + renderWindow.addRenderer(renderer); + renderer.setBackground(0.3, 0.3, 0.3); + + const imageDimension = 48; + + // Background image with gradient, rendered as a full-extent MAX slab + const bgImage = gc.registerResource(vtkImageData.newInstance()); + bgImage.setDimensions(imageDimension, imageDimension, imageDimension); + bgImage.setSpacing(1, 1, 1); + bgImage.setOrigin(0, 0, 0); + + const bgValues = new Uint8Array(imageDimension ** 3); + for (let k = 0; k < imageDimension; k++) { + for (let j = 0; j < imageDimension; j++) { + for (let i = 0; i < imageDimension; i++) { + const idx = i + imageDimension * (j + imageDimension * k); + bgValues[idx] = Math.floor(((i + j) / (2 * imageDimension)) * 255); + } + } + } + + const bgScalars = gc.registerResource( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: bgValues, + }) + ); + bgImage.getPointData().setScalars(bgScalars); + + // Labelmap with two spherical segments placed AWAY from the slice plane + // (z = 10): label 1 around z = 5, label 2 around z = 15. Their outlines can + // only appear through the slab projection of label presence. + const labelImage = gc.registerResource(vtkImageData.newInstance()); + labelImage.setDimensions(imageDimension, imageDimension, imageDimension); + labelImage.setSpacing(1, 1, 1); + labelImage.setOrigin(0, 0, 0); + + const sphereCenters = [ + { c: [15, 24, 12], r: 9, label: 1 }, + { c: [33, 24, 36], r: 9, label: 2 }, + ]; + const labelValues = new Uint8Array(imageDimension ** 3); + sphereCenters.forEach(({ c, r, label }) => { + for (let k = 0; k < imageDimension; k++) { + for (let j = 0; j < imageDimension; j++) { + for (let i = 0; i < imageDimension; i++) { + const dx = i - c[0]; + const dy = j - c[1]; + const dz = k - c[2]; + if (Math.sqrt(dx * dx + dy * dy + dz * dz) <= r) { + const idx = i + imageDimension * (j + imageDimension * k); + labelValues[idx] = label; + } + } + } + } + }); + + const labelScalars = gc.registerResource( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: labelValues, + }) + ); + labelImage.getPointData().setScalars(labelScalars); + + // Shared slice plane through the volume center + const slicePlane = gc.registerResource(vtkPlane.newInstance()); + slicePlane.setNormal(0, 0, 1); + slicePlane.setOrigin(bgImage.getCenter()); + + // Background pipeline + const bgMapper = gc.registerResource(vtkImageResliceMapper.newInstance()); + bgMapper.setSlicePlane(slicePlane); + bgMapper.setSlabType(SlabTypes.MAX); + bgMapper.setSlabThickness(imageDimension); + bgMapper.setInputData(bgImage); + + const bgPpty = gc.registerResource(vtkImageProperty.newInstance()); + bgPpty.setColorWindow(255); + bgPpty.setColorLevel(127); + + const bgActor = gc.registerResource(vtkImageSlice.newInstance()); + bgActor.setMapper(bgMapper); + bgActor.setProperty(bgPpty); + renderer.addActor(bgActor); + + // Labelmap pipeline (single component, dependent components) over the + // same slab + const labelMapper = gc.registerResource( + vtkImageResliceMapper.newInstance() + ); + labelMapper.setSlicePlane(slicePlane); + labelMapper.setSlabType(SlabTypes.MAX); + labelMapper.setSlabThickness(imageDimension); + labelMapper.setInputData(labelImage); + + const labelPpty = gc.registerResource(vtkImageProperty.newInstance()); + labelPpty.setIndependentComponents(false); + labelPpty.setUseLookupTableScalarRange(true); + labelPpty.setInterpolationTypeToNearest(); + labelPpty.setUseLabelOutline(true); + labelPpty.setLabelOutlineThickness([1, 2]); + labelPpty.setLabelOutlineOpacity(1.0); + + const labelRgb = gc.registerResource( + vtkColorTransferFunction.newInstance() + ); + labelRgb.addRGBPoint(0, 0, 0, 0); + labelRgb.addRGBPoint(1, 1, 0, 0); // Red + labelRgb.addRGBPoint(2, 0, 1, 0); // Green + labelPpty.setRGBTransferFunction(0, labelRgb); + + const labelOfun = gc.registerResource(vtkPiecewiseFunction.newInstance()); + labelOfun.addPoint(0, 0); + labelOfun.addPoint(1, 0.3); + labelOfun.addPoint(2, 0.3); + labelPpty.setPiecewiseFunction(0, labelOfun); + + const labelActor = gc.registerResource(vtkImageSlice.newInstance()); + labelActor.setMapper(labelMapper); + labelActor.setProperty(labelPpty); + renderer.addActor(labelActor); + + renderer.resetCamera(); + renderer.getActiveCamera().dolly(1.6); + renderer.resetCameraClippingRange(); + + const promise = glwindow.captureNextImage().then((image) => + testUtils.compareImages( + image, + [baseline], + 'Rendering/Core/ImageResliceMapper', + { + pixelThreshold: 0.005, + mismatchTolerance: 2, + } + ) + ); + renderWindow.render(); + await promise; + + gc.releaseResources(); + } +); diff --git a/Sources/Rendering/Core/ImageResliceMapper/test/testImageResliceMapperLabelOutlineSlab.png b/Sources/Rendering/Core/ImageResliceMapper/test/testImageResliceMapperLabelOutlineSlab.png new file mode 100644 index 00000000000..94e48921364 Binary files /dev/null and b/Sources/Rendering/Core/ImageResliceMapper/test/testImageResliceMapperLabelOutlineSlab.png differ diff --git a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js index c6fc9831f1e..529c2b5b55b 100644 --- a/Sources/Rendering/OpenGL/ImageResliceMapper/index.js +++ b/Sources/Rendering/OpenGL/ImageResliceMapper/index.js @@ -1256,15 +1256,24 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { '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.', + 'bool negExited = false;', + 'bool posExited = false;', 'while (distTraveled < slabThickness * 0.5)', '{', ' 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;', ' vec3 fragTCoordNeg = (WCTCMatrix0 * vec4(worldPosNeg, 1.0)).xyz;', @@ -1274,6 +1283,10 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { ' tvalue = compositeValue(tvalue, newVal, trapezoid);', ' numSlices += 1;', ' }', + ' else if (!atSlabBoundary)', + ' {', + ' negExited = true;', + ' }', ' vec3 worldPosPos = vertexWCVSOutput.xyz + fnumSlices * normalxspacing * vboScaling;', ' vec3 fragTCoordPos = (WCTCMatrix0 * vec4(worldPosPos, 1.0)).xyz;', ' if (!any(greaterThan(fragTCoordPos, vec3(1.0))) && !any(lessThan(fragTCoordPos, vec3(0.0))))', @@ -1282,6 +1295,11 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { ' tvalue = compositeValue(tvalue, newVal, trapezoid);', ' numSlices += 1;', ' }', + ' else if (!atSlabBoundary)', + ' {', + ' posExited = true;', + ' }', + ' if (negExited && posExited) { break; }', '}', '// Finally, if slab type is *mean*, divide the sum by the numSlices', 'if (slabType == 2)', @@ -1291,6 +1309,161 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { ]; } + function getSlabLabelMaskDecLines() { + return [ + '// Returns a bitmask of the labels (1..31) present along the slab', + '// centered at startTC. Marches in texture space and exits as soon as', + '// the ray leaves the unit cube, so only the in-volume portion of the', + '// slab is sampled regardless of how large the slab thickness is.', + 'int labelSlabMask(vec3 startTC, vec3 stepTC, float halfSlab, float stepLen)', + '{', + ' int mask = 0;', + ' vec3 tc = startTC;', + ' float dist = 0.0;', + ' for (int i = 0; i < 4096; ++i)', + ' {', + ' if (dist > halfSlab) { break; }', + ' if (any(greaterThan(tc, vec3(1.0))) || any(lessThan(tc, vec3(0.0)))) { break; }', + ' int label = int(texture(volumeTexture[0], tc).r * 255.0 + 0.5);', + ' if (label > 0 && label < 32) { mask |= (1 << label); }', + ' tc += stepTC;', + ' dist += stepLen;', + ' }', + ' tc = startTC - stepTC;', + ' dist = stepLen;', + ' for (int i = 0; i < 4096; ++i)', + ' {', + ' if (dist > halfSlab) { break; }', + ' if (any(greaterThan(tc, vec3(1.0))) || any(lessThan(tc, vec3(0.0)))) { break; }', + ' int label = int(texture(volumeTexture[0], tc).r * 255.0 + 0.5);', + ' if (label > 0 && label < 32) { mask |= (1 << label); }', + ' tc -= stepTC;', + ' dist += stepLen;', + ' }', + ' return mask;', + '}', + '', + '// Number of stepTC-sized steps from p that stay inside the unit cube', + 'float labelSlabBoxSteps(vec3 p, vec3 stepTC)', + '{', + ' vec3 limit = vec3(65536.0);', + ' if (stepTC.x > 1e-8) { limit.x = (1.0 - p.x) / stepTC.x; }', + ' else if (stepTC.x < -1e-8) { limit.x = -p.x / stepTC.x; }', + ' if (stepTC.y > 1e-8) { limit.y = (1.0 - p.y) / stepTC.y; }', + ' else if (stepTC.y < -1e-8) { limit.y = -p.y / stepTC.y; }', + ' if (stepTC.z > 1e-8) { limit.z = (1.0 - p.z) / stepTC.z; }', + ' else if (stepTC.z < -1e-8) { limit.z = -p.z / stepTC.z; }', + ' return min(limit.x, min(limit.y, limit.z));', + '}', + '', + '// First label of labelMask found when marching the slab from its', + '// viewer-side end toward the back, so overlapping labels resolve in', + '// depth order; returns 0 when none of the mask labels is found', + 'int labelSlabFrontLabel(vec3 startTC, vec3 towardCameraTC, float halfSlab, float stepLen, int labelMask)', + '{', + ' float slabSteps = halfSlab / stepLen;', + ' float nFront = min(slabSteps, labelSlabBoxSteps(startTC, towardCameraTC));', + ' float nBack = min(slabSteps, labelSlabBoxSteps(startTC, -towardCameraTC));', + ' vec3 tc = startTC + towardCameraTC * nFront;', + ' int totalSteps = int(nFront + nBack) + 1;', + ' for (int i = 0; i < 8192; ++i)', + ' {', + ' if (i >= totalSteps) { break; }', + ' int label = int(texture(volumeTexture[0], tc).r * 255.0 + 0.5);', + ' if (label > 0 && label < 32 && (labelMask & (1 << label)) != 0) { return label; }', + ' tc -= towardCameraTC;', + ' }', + ' return 0;', + '}', + ]; + } + + // Slab-projected label outline for a single-component labelmap: labels are + // projected across the slab and the outline is drawn where a label's + // projected footprint ends. The single-slice border detection cannot be + // used here since comparing the slab-composited center value against + // single-slice neighbors classifies the entire projected footprint as + // border. Expects the locals declared by getSlabSampleLoopLines() + // (scaling, slabNormal) and the helpers from getSlabLabelMaskDecLines(). + // + // The block assigns gl_FragData[0] exactly once and never returns early: + // render passes append code after the mapper's fragment block (e.g. the + // order-independent translucency pass premultiplies gl_FragData[0] by a + // depth weight and writes that weight to a second render target), so an + // early return would leave those outputs unwritten and corrupt the pass's + // resolve step. Transparent fragments fall through with alpha 0 instead. + function getSlabLabelOutlineImplLines() { + return splitStringOnEnter(` + // Slab-projected label outline for single component + vec4 slabOutlineResult = vec4(0.0, 0.0, 0.0, 0.0); + // For MAX slabs, a composited value of 0 means no label anywhere + // along the slab, so the expensive mask marches can be skipped. + if (slabType != 1 || tvalue.r >= 0.5 / 255.0) { + vec3 outlineStepTC = (WCTCMatrix0 * vec4(scaling * slabNormal * vboScaling, 0.0)).xyz; + float halfSlab = slabThickness * 0.5; + int centerMask = labelSlabMask(fragTexCoord, outlineStepTC, halfSlab, scaling); + + if (centerMask != 0) { + // A label is on its projected edge when present here but missing in + // some neighbor probed at that label's own outline thickness. Labels + // sharing a thickness reuse the same neighbor masks, so the common + // case still costs four neighbor marches. + float labelmapRow = 0.5 / numLabelmaps; + int edgeLabels = 0; + int prevThickness = -1; + int neighborMask = 0; + for (int s = 1; s < 32; ++s) { + if ((centerMask & (1 << s)) == 0) { continue; } + float thicknessCoordinate = (float(s) - 1.0) / labelOutlineTextureWidth; + int segmentThickness = max(1, int(texture2D(labelOutlineThicknessTexture, vec2(thicknessCoordinate, labelmapRow)).r * 255.0)); + if (segmentThickness != prevThickness) { + vec3 outlineOffset1 = outlineTangent1_0 * texelSize0 * float(segmentThickness); + vec3 outlineOffset2 = outlineTangent2_0 * texelSize0 * float(segmentThickness); + neighborMask = + labelSlabMask(fragTexCoord + outlineOffset1, outlineStepTC, halfSlab, scaling) & + labelSlabMask(fragTexCoord - outlineOffset1, outlineStepTC, halfSlab, scaling) & + labelSlabMask(fragTexCoord + outlineOffset2, outlineStepTC, halfSlab, scaling) & + labelSlabMask(fragTexCoord - outlineOffset2, outlineStepTC, halfSlab, scaling); + prevThickness = segmentThickness; + } + if ((neighborMask & (1 << s)) == 0) { edgeLabels |= (1 << s); } + } + + // When several edge labels compete for this fragment, the one nearest + // the viewer along the slab wins. The camera looks down -z in view + // coordinates, so the slab normal points toward the viewer when its + // view-space z component is positive. + float slabNormalTowardCamera = (MCVCMatrix * vec4(slabNormal, 0.0)).z; + vec3 towardCameraTC = slabNormalTowardCamera > 0.0 ? outlineStepTC : -outlineStepTC; + + if (edgeLabels != 0) { + int edgeLabel = labelSlabFrontLabel(fragTexCoord, towardCameraTC, halfSlab, scaling, edgeLabels); + if (edgeLabel == 0) { + // numeric fallback: smallest edge label + for (int s = 1; s < 32; ++s) { + if ((edgeLabels & (1 << s)) != 0) { edgeLabel = s; break; } + } + } + float labelValue = float(edgeLabel) / 255.0; + vec3 edgeColor = texture2D(colorTexture1, vec2(labelValue * cscale0 + cshift0, 0.5)).rgb; + float opacityCoordinate = (float(edgeLabel) - 1.0) / labelOutlineTextureWidth; + float edgeOpacity = texture2D(labelOutlineOpacityTexture, vec2(opacityCoordinate, labelmapRow)).r; + slabOutlineResult = vec4(edgeColor, edgeOpacity); + } else { + // Interior of the projected labels: regular fill through the + // transfer functions using the label nearest the viewer + int fillLabel = labelSlabFrontLabel(fragTexCoord, towardCameraTC, halfSlab, scaling, centerMask); + float fillValue = fillLabel != 0 ? float(fillLabel) / 255.0 : tvalue.r; + vec3 fillColor = texture2D(colorTexture1, vec2(fillValue * cscale0 + cshift0, 0.5)).rgb; + float fillOpacity = texture2D(pwfTexture1, vec2(fillValue * pwfscale0 + pwfshift0, 0.5)).r; + slabOutlineResult = vec4(fillColor, fillOpacity * opacity); + } + } + } + gl_FragData[0] = slabOutlineResult; + `); + } + function collectLabelmapInputIndices(numberOfComponents, isLabelmap) { const labelmapInputs = []; for (let i = 0; i < numberOfComponents; i++) { @@ -1450,6 +1623,11 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { 'uniform vec3 vboScaling;', ]); tcoordFSDec = tcoordFSDec.concat(getSlabCompositeDecLines()); + if (useLabelOutline && !iComps && tNumComp === 1) { + // view matrix used to resolve which slab end faces the camera + tcoordFSDec = tcoordFSDec.concat(['uniform mat4 MCVCMatrix;']); + tcoordFSDec = tcoordFSDec.concat(getSlabLabelMaskDecLines()); + } } FSSource = vtkShaderProgram.substitute( FSSource, @@ -1516,7 +1694,9 @@ function vtkOpenGLImageResliceMapper(publicAPI, model) { // dependent components switch (tNumComp) { case 1: - if (useLabelOutline) { + if (useLabelOutline && slabThickness > 0.0) { + tcoordFSImpl = tcoordFSImpl.concat(getSlabLabelOutlineImplLines()); + } else if (useLabelOutline) { tcoordFSImpl = tcoordFSImpl.concat([ ...splitStringOnEnter(` // Label outline mode for single component