diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 20f08e87a1..34c44afb11 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -92,6 +92,10 @@ import { import VideoRedactionTool from './tools/annotation/VideoRedactionTool'; import * as Enums from './enums'; +import { + sampleVoxelsFromCanvas, + getCanvasVoxelSamplingStep, +} from './utilities/sampleVoxelsFromCanvas'; export { VideoRedactionTool, @@ -193,6 +197,7 @@ export { WholeBodySegmentTool, LabelmapBaseTool, LabelMapEditWithContourTool, + sampleVoxelsFromCanvas, // Spline classes splines, // Version diff --git a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts index 88e23e2b5c..c9808e0cf5 100644 --- a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts +++ b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts @@ -40,13 +40,16 @@ import type { } from '../../types/ToolSpecificAnnotationTypes'; import type { PlanarFreehandROICommonData } from '../../utilities/math/polyline/planarFreehandROIInternalTypes'; -import { getLineSegmentIntersectionsCoordinates } from '../../utilities/math/polyline'; +import { getIntersectionIterator } from '../../utilities/math/polyline'; import { isViewportPreScaled } from '../../utilities/viewport/isViewportPreScaled'; import { BasicStatsCalculator } from '../../utilities/math/basic'; import ContourSegmentationBaseTool from '../base/ContourSegmentationBaseTool'; import { KeyboardBindings, ChangeTypes, MeasurementType } from '../../enums'; import { getPixelValueUnits } from '../../utilities/getPixelValueUnits'; -import snapIndexBounds from '../../utilities/boundingBox/snapIndexBounds'; +import { + getCanvasVoxelSamplingStep, + sampleVoxelsFromCanvas, +} from '../../utilities/sampleVoxelsFromCanvas'; const { pointCanProjectOnLine } = polyline; const { EPSILON } = CONSTANTS; @@ -910,43 +913,6 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool { const { areaUnit, unit } = calibratedScale; const indexPoints = points.map((point) => imageData.worldToIndex(point)); - const dims = imageData.getDimensions(); - - let iMin = Number.MAX_SAFE_INTEGER; - let iMax = Number.MIN_SAFE_INTEGER; - let jMin = Number.MAX_SAFE_INTEGER; - let jMax = Number.MIN_SAFE_INTEGER; - let kMin = Number.MAX_SAFE_INTEGER; - let kMax = Number.MIN_SAFE_INTEGER; - - for (let j = 0; j < points.length; j++) { - const worldPosIndex = indexPoints[j]; - - iMin = Math.min(iMin, worldPosIndex[0]); - iMax = Math.max(iMax, worldPosIndex[0]); - - jMin = Math.min(jMin, worldPosIndex[1]); - jMax = Math.max(jMax, worldPosIndex[1]); - - kMin = Math.min(kMin, worldPosIndex[2]); - kMax = Math.max(kMax, worldPosIndex[2]); - } - - // Clamp the accumulated bounds into the image extent so the bounding box - // never spills outside the volume. Clamping the min/max here is equivalent - // to clamping every point (clamp is monotonic, so it commutes with - // min/max) but avoids the per-point allocation. The values are left - // unfloored so snapIndexBounds can still detect planar (delta <= 1) ROIs. - iMin = Math.max(0, Math.min(dims[0] - 1, iMin)); - iMax = Math.max(0, Math.min(dims[0] - 1, iMax)); - jMin = Math.max(0, Math.min(dims[1] - 1, jMin)); - jMax = Math.max(0, Math.min(dims[1] - 1, jMax)); - kMin = Math.max(0, Math.min(dims[2] - 1, kMin)); - kMax = Math.max(0, Math.min(dims[2] - 1, kMax)); - - [iMin, iMax] = snapIndexBounds(iMin, iMax); - [jMin, jMax] = snapIndexBounds(jMin, jMax); - [kMin, kMax] = snapIndexBounds(kMin, kMax); // Convert from canvas_pixels ^2 to mm^2 const area = polyline.getArea(canvasCoordinates) * deltaInX * deltaInY; @@ -957,74 +923,33 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool { closed ); - // Expand bounding box - const iDelta = 0.01 * (iMax - iMin); - const jDelta = 0.01 * (jMax - jMin); - const kDelta = 0.01 * (kMax - kMin); - - iMin = Math.floor(iMin - iDelta); - iMax = Math.ceil(iMax + iDelta); - jMin = Math.floor(jMin - jDelta); - jMax = Math.ceil(jMax + jDelta); - kMin = Math.floor(kMin - kDelta); - kMax = Math.ceil(kMax + kDelta); - - const boundsIJK = [ - [iMin, iMax], - [jMin, jMax], - [kMin, kMax], - ] as [Types.Point2, Types.Point2, Types.Point2]; - - const worldPosEnd = imageData.indexToWorld([iMax, jMax, kMax]); - const canvasPosEnd = viewport.worldToCanvas(worldPosEnd); - - let curRow = 0; - let intersections = []; - let intersectionCounter = 0; - - let pointsInShape; - if (voxelManager) { - pointsInShape = voxelManager.forEach( - this.configuration.statsCalculator.statsCallback, - { - imageData, - isInObject: (pointLPS, _pointIJK) => { - let result = true; - const point = viewport.worldToCanvas(pointLPS); - if (point[1] != curRow) { - intersectionCounter = 0; - curRow = point[1]; - intersections = getLineSegmentIntersectionsCoordinates( - canvasCoordinates, - point, - [canvasPosEnd[0], point[1]] - ); - intersections.sort( - (function (index) { - return function (a, b) { - return a[index] === b[index] - ? 0 - : a[index] < b[index] - ? -1 - : 1; - }; - })(0) - ); - } - if (intersections.length && point[0] > intersections[0][0]) { - intersections.shift(); - intersectionCounter++; - } - if (intersectionCounter % 2 === 0) { - result = false; - } - return result; - }, - boundsIJK, - returnPoints: this.configuration.storePointData, - } - ); - } + // Viewport-space ROI sampling for both orthogonal and oblique MPR views. + // + // Instead of iterating a 3D IJK bounding box, we rasterize the ROI in + // canvas space, project each canvas pixel back to world space, convert + // to IJK, and sample the corresponding voxel. + // + // This avoids the instability of voxel-driven scanline traversal in + // oblique views, where IJK iteration order no longer matches canvas + // row order, leading to missing voxels and invalid statistics. + // + // Complexity scales with projected ROI size rather than IJK volume, + // making it stable and efficient for all viewport orientations. + + const canvasStep = getCanvasVoxelSamplingStep(viewport, imageData); + const pixelIterator = getIntersectionIterator( + canvasCoordinates, + canvasStep + ); + + const pointsInShape = sampleVoxelsFromCanvas({ + iterator: pixelIterator, + imageData, + viewport, + voxelManager, + statsCallback: this.configuration.statsCalculator.statsCallback, + }); + const stats = this.configuration.statsCalculator.getStatistics(); const namedArea: Statistics = { diff --git a/packages/tools/src/utilities/math/polyline/computeEffectiveVoxelSpacing.ts b/packages/tools/src/utilities/math/polyline/computeEffectiveVoxelSpacing.ts new file mode 100644 index 0000000000..050cd99bbe --- /dev/null +++ b/packages/tools/src/utilities/math/polyline/computeEffectiveVoxelSpacing.ts @@ -0,0 +1,51 @@ +import type { Types } from '@cornerstonejs/core'; +import { vec3 } from 'gl-matrix'; + +/** + * Computes the effective voxel spacing along an arbitrary world-space direction. + * + * For a unit direction vector `d` and volume axes i, j, k with spacings + * s_i, s_j, s_k, the effective spacing is: + * + * s_eff = 1 / sqrt( (d·i / s_i)² + (d·j / s_j)² + (d·k / s_k)² ) + * + * This represents the world-space distance you must travel along `d` to cross + * one voxel-equivalent boundary. It naturally reduces to the orthogonal case: + * when `d` is aligned with axis i, d·j = d·k = 0, so s_eff = s_i. + * + * @param direction - A normalized world-space direction vector. + * @param iVector - The volume's I direction (row). + * @param jVector - The volume's J direction (column). + * @param kVector - The volume's K direction (slice). + * @param volumeSpacing - The [i, j, k] voxel spacings. + * @returns The effective spacing along `direction`. + */ +function computeEffectiveVoxelSpacing( + direction: Types.Point3, + iVector: Types.Point3, + jVector: Types.Point3, + kVector: Types.Point3, + volumeSpacing: number[] +): number { + const dotI = vec3.dot( + direction as unknown as vec3, + iVector as unknown as vec3 + ); + const dotJ = vec3.dot( + direction as unknown as vec3, + jVector as unknown as vec3 + ); + const dotK = vec3.dot( + direction as unknown as vec3, + kVector as unknown as vec3 + ); + + const sum = + (dotI * dotI) / (volumeSpacing[0] * volumeSpacing[0]) + + (dotJ * dotJ) / (volumeSpacing[1] * volumeSpacing[1]) + + (dotK * dotK) / (volumeSpacing[2] * volumeSpacing[2]); + + return 1.0 / Math.sqrt(sum); +} + +export default computeEffectiveVoxelSpacing; diff --git a/packages/tools/src/utilities/math/polyline/getIntersectionIterator.ts b/packages/tools/src/utilities/math/polyline/getIntersectionIterator.ts new file mode 100644 index 0000000000..6de24b0369 --- /dev/null +++ b/packages/tools/src/utilities/math/polyline/getIntersectionIterator.ts @@ -0,0 +1,57 @@ +import type { Types } from '@cornerstonejs/core'; +import getAABB from './getAABB'; +import getLineSegmentIntersectionsCoordinates from './getLineSegmentIntersectionsCoordinates'; + +/** + * A 2D canvas-space scanline rasterizer that identifies all screen pixels + * residing inside a closed hand-drawn ROI polygon. + * @param canvasCoordinates - An array of 2D points `[x, y]` defining the boundary of the drawn ROI. + * @param canvasStep - Canvas-space step between sample points. Use a value less than 1 when + * zoomed out so adjacent samples stay within one voxel in index space. + * @returns A generator that yields individual `[cx, cy]` canvas pixel coordinates located inside the ROI. + */ +function* getIntersectionIterator(canvasCoordinates, canvasStep = 1) { + const { + maxX: canvasMaxX, + maxY: canvasMaxY, + minX: canvasMinX, + minY: canvasMinY, + } = getAABB(canvasCoordinates); + + const startX = Math.floor(canvasMinX); + const endX = Math.ceil(canvasMaxX); + const startY = Math.floor(canvasMinY); + const endY = Math.ceil(canvasMaxY); + + const canvasMaxXPadded = endX + 1; + + for (let cy = startY; cy <= endY; cy += canvasStep) { + // Compute all intersections of the polygon with this scanline row + const intersections = getLineSegmentIntersectionsCoordinates( + canvasCoordinates, + [startX - 1, cy] as Types.Point2, + [canvasMaxXPadded, cy] as Types.Point2 + ); + + if (!intersections || intersections.length === 0) { + continue; + } + + // Sort intersections by X coordinate + intersections.sort((a, b) => a[0] - b[0]); + + // Walk through intersection pairs (entry/exit) + for (let i = 0; i + 1 < intersections.length; i += 2) { + const xEnter = intersections[i][0]; + const xExit = intersections[i + 1][0]; + const firstX = Math.ceil(xEnter / canvasStep) * canvasStep; + const lastX = Math.floor(xExit / canvasStep) * canvasStep; + + for (let cx = firstX; cx <= lastX; cx += canvasStep) { + yield [cx, cy]; + } + } + } +} + +export default getIntersectionIterator; diff --git a/packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts b/packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts index bdb91957ea..ea1e837900 100644 --- a/packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts +++ b/packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts @@ -1,6 +1,7 @@ import { StackViewport } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import { vec3 } from 'gl-matrix'; +import computeEffectiveVoxelSpacing from './computeEffectiveVoxelSpacing'; const EPSILON = 1e-3; @@ -47,9 +48,12 @@ const getSubPixelSpacingAndXYDirections = ( const jVector = direction.slice(3, 6) as Types.Point3; const kVector = direction.slice(6, 9) as Types.Point3; - const viewRight = vec3.create(); // Get the X direction of the viewport + const normalizedViewUp = vec3.create(); + vec3.normalize(normalizedViewUp, viewUp); - vec3.cross(viewRight, viewUp, viewPlaneNormal); + const viewRight = vec3.create(); // Get the X direction of the viewport + vec3.cross(viewRight, normalizedViewUp, viewPlaneNormal); + vec3.normalize(viewRight, viewRight); const absViewRightDotI = Math.abs(vec3.dot(viewRight, iVector)); const absViewRightDotJ = Math.abs(vec3.dot(viewRight, jVector)); @@ -67,12 +71,22 @@ const getSubPixelSpacingAndXYDirections = ( xSpacing = volumeSpacing[2]; xDir = kVector; } else { - throw new Error('No support yet for oblique plane planar contours'); + // Oblique plane: viewRight is not aligned with any single volume axis. + // Compute effective spacing by projecting the view direction onto + // the volume's voxel grid. + xSpacing = computeEffectiveVoxelSpacing( + viewRight as unknown as Types.Point3, + iVector, + jVector, + kVector, + volumeSpacing + ); + xDir = Array.from(viewRight) as Types.Point3; } - const absViewUpDotI = Math.abs(vec3.dot(viewUp, iVector)); - const absViewUpDotJ = Math.abs(vec3.dot(viewUp, jVector)); - const absViewUpDotK = Math.abs(vec3.dot(viewUp, kVector)); + const absViewUpDotI = Math.abs(vec3.dot(normalizedViewUp, iVector)); + const absViewUpDotJ = Math.abs(vec3.dot(normalizedViewUp, jVector)); + const absViewUpDotK = Math.abs(vec3.dot(normalizedViewUp, kVector)); // Get Y spacing let ySpacing; @@ -86,7 +100,15 @@ const getSubPixelSpacingAndXYDirections = ( ySpacing = volumeSpacing[2]; yDir = kVector; } else { - throw new Error('No support yet for oblique plane planar contours'); + // Oblique plane: compute effective spacing along viewUp + ySpacing = computeEffectiveVoxelSpacing( + normalizedViewUp as unknown as Types.Point3, + iVector, + jVector, + kVector, + volumeSpacing + ); + yDir = Array.from(normalizedViewUp) as Types.Point3; } spacing = [xSpacing, ySpacing]; diff --git a/packages/tools/src/utilities/math/polyline/index.ts b/packages/tools/src/utilities/math/polyline/index.ts index 8e7660ba7a..87ddea8cd6 100644 --- a/packages/tools/src/utilities/math/polyline/index.ts +++ b/packages/tools/src/utilities/math/polyline/index.ts @@ -17,6 +17,8 @@ import getLineSegmentIntersectionsIndexes from './getLineSegmentIntersectionsInd import getLineSegmentIntersectionsCoordinates from './getLineSegmentIntersectionsCoordinates'; import getClosestLineSegmentIntersection from './getClosestLineSegmentIntersection'; import getSubPixelSpacingAndXYDirections from './getSubPixelSpacingAndXYDirections'; +import getIntersectionIterator from './getIntersectionIterator'; +import computeEffectiveVoxelSpacing from './computeEffectiveVoxelSpacing'; import pointsAreWithinCloseContourProximity from './pointsAreWithinCloseContourProximity'; import addCanvasPointsToArray from './addCanvasPointsToArray'; import pointCanProjectOnLine from './pointCanProjectOnLine'; @@ -42,6 +44,8 @@ export { getLineSegmentIntersectionsCoordinates, getClosestLineSegmentIntersection, getSubPixelSpacingAndXYDirections, + getIntersectionIterator, + computeEffectiveVoxelSpacing, pointsAreWithinCloseContourProximity, addCanvasPointsToArray, pointCanProjectOnLine, diff --git a/packages/tools/src/utilities/sampleVoxelsFromCanvas.ts b/packages/tools/src/utilities/sampleVoxelsFromCanvas.ts new file mode 100644 index 0000000000..6618c549cd --- /dev/null +++ b/packages/tools/src/utilities/sampleVoxelsFromCanvas.ts @@ -0,0 +1,141 @@ +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { Types } from '@cornerstonejs/core'; +import { utilities } from '@cornerstonejs/core'; + +function getIJKDistance(a: Types.Point3, b: Types.Point3): number { + const di = a[0] - b[0]; + const dj = a[1] - b[1]; + const dk = a[2] - b[2]; + return Math.sqrt(di * di + dj * dj + dk * dk); +} + +/** + * Returns the canvas step size needed so that adjacent sample points are at most + * one voxel apart in index space. Returns 1 when zoomed in enough that each canvas + * pixel already spans less than one voxel. + */ +export function getCanvasVoxelSamplingStep( + viewport: Types.IViewport, + imageData: vtkImageData | Types.CPUImageData +): number { + const canvas = viewport.getCanvas(); + const refX = (canvas?.clientWidth ?? 1) / 2; + const refY = (canvas?.clientHeight ?? 1) / 2; + + const indexAtRef = utilities.transformWorldToIndex( + imageData, + viewport.canvasToWorld([refX, refY]) + ) as Types.Point3; + const indexAtRefPlusX = utilities.transformWorldToIndex( + imageData, + viewport.canvasToWorld([refX + 1, refY]) + ) as Types.Point3; + const indexAtRefPlusY = utilities.transformWorldToIndex( + imageData, + viewport.canvasToWorld([refX, refY + 1]) + ) as Types.Point3; + + const maxDist = Math.max( + getIJKDistance(indexAtRef, indexAtRefPlusX), + getIJKDistance(indexAtRef, indexAtRefPlusY) + ); + + if (maxDist <= 1) { + return 1; + } + + return 1 / Math.ceil(maxDist); +} + +type statsCallback = ({ + value, + pointLPS, + pointIJK, +}: { + value: number | Types.RGB; + pointLPS?: Types.Point3 | null; + pointIJK?: Types.Point3 | null; +}) => void; + +/** + * Takes 2D canvas pixels, projects them into 3D medical image space, + * and samples the underlying voxels to compute statistical metrics (mean, min, max, etc.). + * @param options.iterator - A generator yielding `[cx, cy]` canvas coordinates to evaluate. + * @param options.viewport - The active Cornerstone3D viewport context. + * @param options.imageData - The underlying VTK or CPU image volume data. + * @param options.voxelManager - voxelManager instance for accessing voxel values at IJK coordinates. + * @param options.statsCallback - A callback invoked once per unique IJK voxel sampled. + * @returns An array of unique sampled voxels, each containing the voxel value, LPS coordinates, and IJK coordinates. + */ +export function sampleVoxelsFromCanvas({ + iterator, + viewport, + imageData, + voxelManager, + statsCallback, +}: { + iterator: Generator; + viewport: Types.IViewport; + imageData: vtkImageData | Types.CPUImageData; + voxelManager: Types.VoxelManager; + statsCallback: statsCallback; +}): { + value: number; + pointLPS: Types.Point3; + pointIJK: Types.Point3; +}[] { + const dimensions = imageData.getDimensions(); + const pointsInShape = []; + const visitedIJK = new Set(); + + for (const [cx, cy] of iterator) { + const canvasPoint: Types.Point2 = [cx, cy]; + const worldPoint = viewport.canvasToWorld(canvasPoint); + const indexPoint = utilities.transformWorldToIndex(imageData, worldPoint); + + // Nearest-neighbor: round to closest voxel + const ijkPoint: Types.Point3 = [ + Math.round(indexPoint[0]), + Math.round(indexPoint[1]), + Math.round(indexPoint[2]), + ]; + + // Bounds check + if (!isInBound(ijkPoint, dimensions)) { + continue; + } + + const value = voxelManager.getAtIJKPoint(ijkPoint); + + if (value === undefined || value === null) { + continue; + } + + const sample = { + value: value as number, + pointLPS: worldPoint as Types.Point3, + pointIJK: ijkPoint, + }; + + const ijkKey = `${ijkPoint[0]},${ijkPoint[1]},${ijkPoint[2]}`; + if (visitedIJK.has(ijkKey)) { + continue; + } + visitedIJK.add(ijkKey); + + pointsInShape.push(sample); + statsCallback(sample); + } + return pointsInShape; +} + +function isInBound(ijkPoint: Types.Point3, dimensions) { + return !( + ijkPoint[0] < 0 || + ijkPoint[0] >= dimensions[0] || + ijkPoint[1] < 0 || + ijkPoint[1] >= dimensions[1] || + ijkPoint[2] < 0 || + ijkPoint[2] >= dimensions[2] + ); +} diff --git a/packages/tools/test/utilities/getIntersectionIterator.jest.js b/packages/tools/test/utilities/getIntersectionIterator.jest.js new file mode 100644 index 0000000000..ea20be65d0 --- /dev/null +++ b/packages/tools/test/utilities/getIntersectionIterator.jest.js @@ -0,0 +1,127 @@ +import { describe, it, expect } from '@jest/globals'; +import getIntersectionIterator from '../../src/utilities/math/polyline/getIntersectionIterator'; + +/** + * Tests for the `getIntersectionIterator` utility. + * Verifies polyline/ROI polygon rasterization, coordinate rounding, + * fractional step sampling, and lazy generator execution. + */ +describe('getIntersectionIterator', () => { + it('should rasterize a real 3x3 square polygon', () => { + const realCoordinates = [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + [10, 10], + ]; + + const pixels = [...getIntersectionIterator(realCoordinates)]; + + expect(pixels).toEqual([ + [10, 10], + [11, 10], + [12, 10], + [10, 11], + [11, 11], + [12, 11], + [10, 12], + [11, 12], + ]); + }); + + it('should accurately rasterize an ROI with negative and decimal coordinates', () => { + const decimalNegativeCoordinates = [ + [-2.4, -2.4], + [1.6, -2.4], + [1.6, 1.6], + [-2.4, 1.6], + [-2.4, -2.4], + ]; + + const pixels = [...getIntersectionIterator(decimalNegativeCoordinates)]; + + expect(pixels).toEqual([ + // Scanline Y = -2 + [-2, -2], + [-1, -2], + [0, -2], + [1, -2], + // Scanline Y = -1 + [-2, -1], + [-1, -1], + [0, -1], + [1, -1], + // Scanline Y = 0 + [-2, 0], + [-1, 0], + [0, 0], + [1, 0], + // Scanline Y = 1 + [-2, 1], + [-1, 1], + [0, 1], + [1, 1], + ]); + }); + + it('should return an empty array if given no coordinates', () => { + const polygon = []; + + const result = Array.from(getIntersectionIterator(polygon)); + + expect(result).toEqual([]); + }); + + it('should handle a simple triangle ROI', () => { + const triangleROI = [ + [10, 10], + [14, 10], + [10, 14], + ]; + + const result = Array.from(getIntersectionIterator(triangleROI)); + + expect(result.length).toBeGreaterThan(0); + + result.forEach(([cx, cy]) => { + expect(cx).toBeGreaterThanOrEqual(10); + expect(cy).toBeGreaterThanOrEqual(10); + }); + }); + + it('should sample narrow fractional spans when canvasStep is fractional', () => { + const narrowRowCoordinates = [ + [0, 0], + [10.8, 0], + [10.8, 0], + [0, 0], + ]; + + const pixels = [...getIntersectionIterator(narrowRowCoordinates, 1 / 3)]; + + expect(pixels.length).toBeGreaterThan(0); + pixels.forEach(([cx, cy]) => { + expect(cx).toBeGreaterThanOrEqual(0); + expect(cx).toBeLessThanOrEqual(10.8); + expect(cy).toBe(0); + }); + }); + + it('should stream results lazily as a true generator', () => { + const squareROI = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]; + + const iterator = getIntersectionIterator(squareROI); + + const firstPixel = iterator.next(); + + expect(firstPixel.done).toBe(false); + expect(firstPixel.value).toBeDefined(); + expect(firstPixel.value).toHaveLength(2); + }); +});