|
| 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 | +}); |
0 commit comments