Skip to content

Commit 70bc9fa

Browse files
PaulHaxfinetjul
authored andcommitted
fix(ImageMapper): check cardinal neighbors in label outline border detection
The label outline border detection loop used `if (i == 0 || j == 0)` which skips all cardinal-direction neighbors (up/down/left/right), only checking diagonals. Changed to `if (i == 0 && j == 0)` to skip only the center pixel, matching ImageResliceMapper's correct behavior. Fixes #3480
1 parent 920f0ce commit 70bc9fa

3 files changed

Lines changed: 175 additions & 2 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import test from 'tape';
2+
import 'vtk.js/Sources/Rendering/Misc/RenderingAPIs';
3+
4+
import vtkImageMapper from 'vtk.js/Sources/Rendering/Core/ImageMapper';
5+
import vtkImageSlice from 'vtk.js/Sources/Rendering/Core/ImageSlice';
6+
import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer';
7+
import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow';
8+
import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData';
9+
import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction';
10+
import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray';
11+
import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction';
12+
13+
test.onlyIfWebGL('Test label outline detects cardinal neighbors', async (t) => {
14+
// 100x100 image rendered at 100x100 canvas = 1:1 pixel mapping.
15+
// An isolated red pixel (label 1) at the center is surrounded by
16+
// green (label 5). With outline thickness=1, all 8 green neighbors
17+
// should be detected as border and rendered with full outline opacity.
18+
const SIZE = 100;
19+
20+
const container = document.querySelector('body');
21+
const renderWindowContainer = document.createElement('div');
22+
container.appendChild(renderWindowContainer);
23+
24+
const renderWindow = vtkRenderWindow.newInstance();
25+
const renderer = vtkRenderer.newInstance();
26+
renderWindow.addRenderer(renderer);
27+
renderer.setBackground(0, 0, 0);
28+
29+
const imageData = vtkImageData.newInstance();
30+
imageData.setSpacing(1, 1, 1);
31+
imageData.setOrigin(0, 0, 0);
32+
imageData.setDirection(1, 0, 0, 0, 1, 0, 0, 0, 1);
33+
imageData.setExtent(0, SIZE - 1, 0, SIZE - 1, 0, 0);
34+
imageData.computeTransforms();
35+
36+
const values = new Uint8Array(SIZE * SIZE);
37+
values.fill(160); // HIGH_VALUE -> label 5 (green)
38+
values[50 * SIZE + 50] = 80; // LOW_VALUE -> label 1 (red) at center
39+
40+
imageData
41+
.getPointData()
42+
.setScalars(vtkDataArray.newInstance({ numberOfComponents: 1, values }));
43+
imageData.modified();
44+
45+
// Create labelmap
46+
const labelMapData = vtkImageData.newInstance(
47+
imageData.get('spacing', 'origin', 'direction')
48+
);
49+
labelMapData.setDimensions(...imageData.getDimensions());
50+
labelMapData.setSpacing(...imageData.getSpacing());
51+
labelMapData.setOrigin(...imageData.getOrigin());
52+
labelMapData.setDirection(...imageData.getDirection());
53+
labelMapData.computeTransforms();
54+
55+
const labelValues = new Uint8Array(SIZE * SIZE);
56+
for (let i = 0; i < values.length; i++) {
57+
if (values[i] === 80) labelValues[i] = 1;
58+
else if (values[i] === 160) labelValues[i] = 5;
59+
}
60+
labelMapData
61+
.getPointData()
62+
.setScalars(
63+
vtkDataArray.newInstance({ numberOfComponents: 1, values: labelValues })
64+
);
65+
66+
const mapper = vtkImageMapper.newInstance();
67+
mapper.setInputData(labelMapData);
68+
const actor = vtkImageSlice.newInstance();
69+
actor.setMapper(mapper);
70+
71+
const cfun = vtkColorTransferFunction.newInstance();
72+
cfun.addRGBPoint(0, 0, 0, 0);
73+
cfun.addRGBPoint(1, 1, 0, 0);
74+
cfun.addRGBPoint(5, 0, 1, 0);
75+
const ofun = vtkPiecewiseFunction.newInstance();
76+
ofun.addPoint(0, 0);
77+
ofun.addPoint(1, 0.5);
78+
ofun.addPoint(5, 0.5);
79+
80+
actor.getProperty().setRGBTransferFunction(0, cfun);
81+
actor.getProperty().setScalarOpacity(0, ofun);
82+
actor.getProperty().setInterpolationTypeToNearest();
83+
actor.getProperty().setUseLabelOutline(true);
84+
actor.getProperty().setUseLookupTableScalarRange(true);
85+
actor.getProperty().setLabelOutlineThickness([1, 1, 1, 1, 1]);
86+
actor.getProperty().setLabelOutlineOpacity(1.0);
87+
88+
renderer.addActor(actor);
89+
90+
const cam = renderer.getActiveCamera();
91+
cam.setParallelProjection(true);
92+
cam.setPosition(49.5, 49.5, 1);
93+
cam.setFocalPoint(49.5, 49.5, 0);
94+
cam.setViewUp(0, 1, 0);
95+
cam.setParallelScale(50);
96+
renderer.resetCameraClippingRange();
97+
98+
const glwindow = renderWindow.newAPISpecificView();
99+
glwindow.setContainer(renderWindowContainer);
100+
renderWindow.addView(glwindow);
101+
glwindow.setSize(SIZE, SIZE);
102+
103+
const imagePromise = glwindow.captureNextImage();
104+
renderWindow.render();
105+
const image = await imagePromise;
106+
107+
// Decode captured image to read pixel values
108+
const img = new Image();
109+
await new Promise((resolve) => {
110+
img.onload = resolve;
111+
img.src = image;
112+
});
113+
114+
const canvas = document.createElement('canvas');
115+
canvas.width = SIZE;
116+
canvas.height = SIZE;
117+
const ctx = canvas.getContext('2d');
118+
ctx.drawImage(img, 0, 0);
119+
const pixelData = ctx.getImageData(0, 0, SIZE, SIZE).data;
120+
121+
const getPixel = (x, y) => {
122+
const idx = (y * SIZE + x) * 4;
123+
return { r: pixelData[idx], g: pixelData[idx + 1], b: pixelData[idx + 2] };
124+
};
125+
126+
// Red pixel at image (50, 50). Screen y = SIZE - 1 - 50 = 49.
127+
const cx = 50;
128+
const cy = SIZE - 1 - 50;
129+
130+
// Outline opacity=1.0 -> green channel = 255 for outline pixels
131+
// Fill opacity=0.5 -> green channel ≈ 128 for non-outline pixels
132+
// Threshold at 200 to distinguish the two
133+
const OUTLINE_THRESHOLD = 200;
134+
135+
const up = getPixel(cx, cy - 1);
136+
const down = getPixel(cx, cy + 1);
137+
const left = getPixel(cx - 1, cy);
138+
const right = getPixel(cx + 1, cy);
139+
140+
const upLeft = getPixel(cx - 1, cy - 1);
141+
const upRight = getPixel(cx + 1, cy - 1);
142+
const downLeft = getPixel(cx - 1, cy + 1);
143+
const downRight = getPixel(cx + 1, cy + 1);
144+
145+
// Diagonal neighbors should always be detected as outline
146+
t.ok(upLeft.g > OUTLINE_THRESHOLD, `upLeft g=${upLeft.g} should be outline`);
147+
t.ok(
148+
upRight.g > OUTLINE_THRESHOLD,
149+
`upRight g=${upRight.g} should be outline`
150+
);
151+
t.ok(
152+
downLeft.g > OUTLINE_THRESHOLD,
153+
`downLeft g=${downLeft.g} should be outline`
154+
);
155+
t.ok(
156+
downRight.g > OUTLINE_THRESHOLD,
157+
`downRight g=${downRight.g} should be outline`
158+
);
159+
160+
// Cardinal neighbors must also be detected as outline.
161+
// This is the actual bug check: with || they get fill (g≈128) not outline (g=255)
162+
t.ok(up.g > OUTLINE_THRESHOLD, `up g=${up.g} should be outline`);
163+
t.ok(down.g > OUTLINE_THRESHOLD, `down g=${down.g} should be outline`);
164+
t.ok(left.g > OUTLINE_THRESHOLD, `left g=${left.g} should be outline`);
165+
t.ok(right.g > OUTLINE_THRESHOLD, `right g=${right.g} should be outline`);
166+
167+
// Clean up
168+
renderWindow.removeView(glwindow);
169+
renderWindow.removeRenderer(renderer);
170+
container.removeChild(renderWindowContainer);
171+
172+
t.end();
173+
});

Sources/Rendering/OpenGL/ImageMapper/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ function vtkOpenGLImageMapper(publicAPI, model) {
389389
390390
for (int i = -actualThickness; i <= actualThickness; i++) {
391391
for (int j = -actualThickness; j <= actualThickness; j++) {
392-
if (i == 0 || j == 0) {
392+
if (i == 0 && j == 0) {
393393
continue;
394394
}
395395
vec4 neighborPixelCoord = vec4(gl_FragCoord.x + float(i),

Sources/Rendering/OpenGL/glsl/vtkVolumeFS.glsl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -946,7 +946,7 @@ vec4 getColorForLabelOutline() {
946946
// TODO define epsilon when building shader?
947947
for (int i = -actualThickness; i <= actualThickness; i++) {
948948
for (int j = -actualThickness; j <= actualThickness; j++) {
949-
if (i == 0 || j == 0) {
949+
if (i == 0 && j == 0) {
950950
continue;
951951
}
952952

0 commit comments

Comments
 (0)