diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index 45308f774..e1e3b4fb2 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -16,6 +16,7 @@ import { getLPSAxisFromDir } from '@/src/utils/lps'; import { useImage } from '@/src/composables/useCurrentImage'; import { updatePlaneManipulatorFor2DView } from '@/src/utils/manipulators'; import { usePaintToolStore } from '@/src/store/tools/paint'; +import { useViewStore } from '@/src/store/views'; import { vtkPaintViewWidget } from '@/src/vtk/PaintWidget'; import { LPSAxisDir } from '@/src/types/lps'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; @@ -51,6 +52,7 @@ export default defineComponent({ const slice = computed(() => sliceInfo.value?.slice); const paintStore = usePaintToolStore(); + const viewStore = useViewStore(); const interactionMode = usePaintInteractionMode(); const sampling = computed( () => interactionMode.value === PaintMode.Eyedropper @@ -153,6 +155,11 @@ export default defineComponent({ let checkIfPointerInView = false; const pointerInView = ref(false); + watchEffect(() => { + if (pointerInView.value && imageId.value) { + paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); + } + }); const cursorStyles = view.widgetManager.getCursorStyles(); watchEffect(() => { widget.setSampling(sampling.value); @@ -173,16 +180,10 @@ export default defineComponent({ if (!checkIfPointerInView) return; checkIfPointerInView = false; pointerInView.value = true; - if (imageId.value) { - paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); - } }; onVTKEvent(view.interactor, 'onMouseMove', showPreviewOnFirstMove); onVTKEvent(view.interactor, 'onMouseEnter', () => { - if (imageId.value) { - paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); - } pointerInView.value = true; }); @@ -200,9 +201,9 @@ export default defineComponent({ const handleWheelEvent = (event: WheelEvent) => { if (!enableBrushSizeAdjustment.value) return; event.preventDefault(); + viewStore.setActiveView(viewId.value); const delta = event.deltaY < 0 ? 1 : -1; - const newSize = Math.max(1, Math.min(50, paintStore.brushSize + delta)); - paintStore.setBrushSize(newSize); + paintStore.setBrushSize(paintStore.brushSize + delta); }; onMounted(() => { diff --git a/src/composables/actions.ts b/src/composables/actions.ts index c2a8b67d1..d515b0460 100644 --- a/src/composables/actions.ts +++ b/src/composables/actions.ts @@ -79,8 +79,7 @@ const deleteCurrentImage = () => () => { const changeBrushSize = (delta: number) => () => { const paintStore = usePaintToolStore(); - const newSize = Math.max(1, paintStore.brushSize + delta); - paintStore.setBrushSize(newSize); + paintStore.setBrushSize(paintStore.brushSize + delta); }; export const ACTION_TO_FUNC = { diff --git a/src/core/tools/paint/ellipse-brush.ts b/src/core/tools/paint/ellipse-brush.ts index 1b71e3ff3..f42979503 100644 --- a/src/core/tools/paint/ellipse-brush.ts +++ b/src/core/tools/paint/ellipse-brush.ts @@ -1,6 +1,14 @@ import type { Vector2 } from '@kitware/vtk.js/types'; import { IBrushStencil, IPaintBrush } from './brush'; +const ceilScaledDiameter = (size: number, scale: number) => { + const diameter = size * scale; + const lower = Math.floor(diameter); + // Bound the rounding from the spacing division and diameter multiplication. + const roundoff = (Number.EPSILON * Math.abs(diameter)) / (1 - Number.EPSILON); + return diameter - lower <= roundoff ? lower : Math.ceil(diameter); +}; + // Adapted from http://members.chello.at/easyfilter/bresenham.html function rasterizeEllipse(xdiam: number, ydiam: number) { const buffer = new Uint8Array(xdiam * ydiam); @@ -81,8 +89,8 @@ export default class EllipsePaintBrush implements IPaintBrush { private recomputeStencil() { const scaledSize = [ - Math.ceil(this.size * this.scale[0]), - Math.ceil(this.size * this.scale[1]), + ceilScaledDiameter(this.size, this.scale[0]), + ceilScaledDiameter(this.size, this.scale[1]), ] as Vector2; this.cachedStencil = { size: scaledSize, diff --git a/src/segmentation/__tests__/brushSize.spec.ts b/src/segmentation/__tests__/brushSize.spec.ts new file mode 100644 index 000000000..2e2d584c5 --- /dev/null +++ b/src/segmentation/__tests__/brushSize.spec.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { disposePinia } from 'pinia'; +import { nextTick } from 'vue'; + +import { useImageStatsStore } from '@/src/store/image-stats'; +import { usePaintToolStore } from '@/src/store/tools/paint'; +import { useViewStore } from '@/src/store/views'; +import { + activateAppPinia, + addMask, + markedVoxels, + parentImage, + seatImage, + selectSegment, + viewImage, + type Index3, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +const CENTER: Index3 = [4, 4, 4]; +const IMAGE_DIMENSIONS: Index3 = [9, 9, 70]; +const SLICE_AXES = [0, 1, 2] as const; + +const paintAt = (imageId: string, axis: 0 | 1 | 2, center = CENTER) => { + const maskId = addMask(imageId, `Plane ${axis}`); + selectSegment(maskId); + const paint = usePaintToolStore(); + paint.setSliceAxis(axis, imageId); + const worldPoint = parentImage(imageId).indexToWorld(center); + paint.startStroke(worldPoint, axis, imageId); + paint.endStroke(worldPoint, axis, imageId); + return markedVoxels(maskId)!; +}; + +const paintAtCenter = (axis: 0 | 1 | 2, size: number) => { + usePaintToolStore().setBrushSize(size); + return paintAt('image', axis); +}; + +const paintedBounds = (painted: number[][]) => + CENTER.flatMap((_, coordinate) => [ + Math.min(...painted.map((voxel) => voxel[coordinate])), + Math.max(...painted.map((voxel) => voxel[coordinate])), + ]); + +describe('brush size on the image grid', () => { + let pinia: ReturnType; + + beforeEach(() => { + pinia = activateAppPinia(); + useImageStatsStore().stats.image = { + scalarMin: 0, + scalarMax: 0, + autoRangeValues: {}, + }; + }); + + afterEach(() => { + disposePinia(pinia); + }); + + it('paints exactly the selected voxel at size 1 in every plane', async () => { + await viewImage('image', { + dimensions: IMAGE_DIMENSIONS, + spacing: [0.379, 0.379, 1.5], + }); + + for (const axis of SLICE_AXES) { + expect(paintAtCenter(axis, 1), `slice axis ${axis}`).toEqual([ + [...CENTER, SEGMENT_VALUE], + ]); + } + }); + + it.each([ + { spacing: [0.1, 0.7, 0.7], expected: [[4, 4, 4]] }, + { spacing: [1, 7, 7], expected: [[4, 4, 4]] }, + { + spacing: [0.1000001, 0.7, 0.7], + expected: [ + [4, 4, 4], + [4, 5, 4], + [4, 4, 5], + [4, 5, 5], + ], + }, + ])( + 'preserves voxel boundaries with spacing $spacing', + async ({ spacing, expected }) => { + await viewImage('image', { + dimensions: IMAGE_DIMENSIONS, + spacing: spacing as Index3, + }); + + expect(paintAtCenter(0, 7)).toEqual( + expected.map((voxel) => [...voxel, SEGMENT_VALUE]) + ); + } + ); + + it('keeps a larger brush at the finest voxel scale when changing planes', async () => { + await viewImage('image', { + dimensions: IMAGE_DIMENSIONS, + spacing: [0.25, 0.5, 1], + }); + + const expectedBounds = [ + [4, 4, 3, 5, 4, 5], + [2, 6, 4, 4, 4, 5], + [2, 6, 3, 5, 4, 4], + ]; + + for (const axis of SLICE_AXES) { + expect( + paintedBounds(paintAtCenter(axis, 5)), + `slice axis ${axis}` + ).toEqual(expectedBounds[axis]); + } + }); + + it('initializes the default brush footprint when activating the tool', async () => { + await viewImage('image', { dimensions: IMAGE_DIMENSIONS }); + const maskId = addMask('image'); + selectSegment(maskId); + const paint = usePaintToolStore(); + + paint.activateTool(); + paint.startStroke(CENTER, 2, 'image'); + paint.endStroke(CENTER, 2, 'image'); + + expect(paintedBounds(markedVoxels(maskId)!)).toEqual([3, 6, 3, 6, 4, 4]); + }); + + it('keeps the chosen footprint when the active view has no image', async () => { + await viewImage('image', { dimensions: IMAGE_DIMENSIONS }); + usePaintToolStore().setBrushSize(3); + + const views = useViewStore(); + const emptyView = views.viewIDs.find((id) => id !== views.activeView)!; + views.setDataForView(emptyView, undefined); + views.setActiveView(emptyView); + await nextTick(); + + expect(paintAt('image', 2)).toEqual([ + [4, 3, 4, SEGMENT_VALUE], + [3, 4, 4, SEGMENT_VALUE], + [4, 4, 4, SEGMENT_VALUE], + [5, 4, 4, SEGMENT_VALUE], + [4, 5, 4, SEGMENT_VALUE], + ]); + }); + + it('fits a smaller image without changing the footprint on the active image', async () => { + await viewImage('image', { dimensions: IMAGE_DIMENSIONS }); + useImageStatsStore().stats.small = { + scalarMin: 0, + scalarMax: 0, + autoRangeValues: {}, + }; + await seatImage('small', { dimensions: [3, 3, 21] }); + usePaintToolStore().setBrushSize(7); + + expect(paintAt('small', 2, [1, 1, 1])).toEqual([ + [1, 0, 1, SEGMENT_VALUE], + [0, 1, 1, SEGMENT_VALUE], + [1, 1, 1, SEGMENT_VALUE], + [2, 1, 1, SEGMENT_VALUE], + [1, 2, 1, SEGMENT_VALUE], + ]); + expect(paintedBounds(paintAt('image', 2))).toEqual([1, 7, 1, 7, 4, 4]); + }); +}); diff --git a/src/segmentation/components/PaintControls.vue b/src/segmentation/components/PaintControls.vue index 482b0b1f0..de8f694e2 100644 --- a/src/segmentation/components/PaintControls.vue +++ b/src/segmentation/components/PaintControls.vue @@ -98,7 +98,7 @@
{{ brushDisabledReason }} - - mdi-diameter-outlineSize - - - - mdi-chart-histogramThreshold - - - - - + activator="parent" + location="top" + > + Brush diameter in voxels at the image's finest spacing. Size 1 + paints one voxel. + + + +
+
+ + mdi-chart-histogramThreshold + + + + + +
{ const { setProcessControlsOpen } = paintStore; const { brushSize, + brushSizeMax, processControlsOpen, isPaintingModeActive, thresholdRange, @@ -394,13 +415,6 @@ const controlPanels = computed({ width: 110px; } -.paint-parameters { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - column-gap: 12px; - align-items: center; -} - .threshold-input :deep(input) { font-size: 0.75rem; font-variant-numeric: tabular-nums; diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 6aba2c873..d2e76923b 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -5,7 +5,7 @@ import type { Manifest, StateFile } from '@/src/io/state-file/schema'; import type { Maybe } from '@/src/types'; import { useImageStatsStore } from '@/src/store/image-stats'; import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; -import { computed, ref, unref, watch } from 'vue'; +import { computed, ref, unref, watch, watchEffect } from 'vue'; import { watchImmediate } from '@vueuse/core'; import { vec3 } from 'gl-matrix'; import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; @@ -27,6 +27,8 @@ import { useViewCameraStore } from '../view-configs/camera'; import { useImageCacheStore } from '../image-cache'; const DEFAULT_BRUSH_SIZE = 4; +const getBrushSizeLimit = (dimensions: Iterable) => + Math.max(1, Math.ceil(Math.max(...dimensions) / 10)); // Growing a mask copies the whole of it, so a stroke that has to grow it asks // for room beyond its footprint and the samples that follow grow nothing. @@ -53,12 +55,23 @@ export const usePaintToolStore = defineStore('paint', () => { // rather than per sample. A stroke that never ended leaves them to the next. const strokeMaskIds = new Set(); - const { currentImageID, currentImageMetadata } = useCurrentImage('global'); + const { currentImageID, currentImageData, currentImageMetadata } = + useCurrentImage('global'); const imageStatsStore = useImageStatsStore(); const viewSliceStore = useViewSliceStore(); const viewStore = useViewStore(); const viewCameraStore = useViewCameraStore(); + const brushSizeMax = computed(() => + getBrushSizeLimit(currentImageMetadata.value.dimensions) + ); + + watchEffect(() => { + if (currentImageData.value && brushSize.value > brushSizeMax.value) { + brushSize.value = brushSizeMax.value; + } + }); + function getWidgetFactory(this: _This) { return this.$paint.factory; } @@ -150,8 +163,14 @@ export const usePaintToolStore = defineStore('paint', () => { * @param size */ function setBrushSize(this: _This, size: number) { - brushSize.value = Math.round(size); - this.$paint.setBrushSize(size); + const max = currentImageData.value + ? brushSizeMax.value + : Number.POSITIVE_INFINITY; + const nextSize = Math.max(1, Math.min(max, Math.round(size))); + // Preserve the hovered image's cap; inactive tools still initialize the stencil. + if (isActive.value && nextSize === brushSize.value) return; + brushSize.value = nextSize; + this.$paint.setBrushSize(nextSize); } function selectSegmentAt(worldPoint: vec3, imageID: string) { @@ -281,9 +300,19 @@ export const usePaintToolStore = defineStore('paint', () => { const imageData = useImageCacheStore().getVtkImageData(imageID); if (!imageData) return; - const spacing = [...imageData.getSpacing()]; + const spacing = imageData.getSpacing().map(Math.abs); + // One size unit follows the same voxel spacing in every slice plane. + const finestSpacing = Math.min(...spacing); spacing.splice(axisIndex, 1); - const scale: Vector2 = [1 / spacing[0], 1 / spacing[1]]; + const scale: Vector2 = [ + finestSpacing / spacing[0], + finestSpacing / spacing[1], + ]; + const max = getBrushSizeLimit(imageData.getDimensions()); + if (imageID === currentImageID.value && this.brushSize > max) { + this.setBrushSize(max); + } + this.$paint.setBrushSize(Math.min(this.brushSize, max)); this.$paint.setBrushScale(scale); } @@ -352,7 +381,7 @@ export const usePaintToolStore = defineStore('paint', () => { // Selecting the tool configures the widget and nothing else. Storage is // allocated by the first stroke, so picking up the brush and putting it // down again leaves the image untouched. - this.$paint.setBrushSize(this.brushSize); + this.setBrushSize(this.brushSize); this.$paint.setBrushValue(SEGMENT_VALUE); isActive.value = true; @@ -442,6 +471,7 @@ export const usePaintToolStore = defineStore('paint', () => { activePaintMode, processControlsOpen, brushSize, + brushSizeMax, strokePoints, isActive, isPaintingModeActive, diff --git a/tests/specs/paint-brush-size.e2e.ts b/tests/specs/paint-brush-size.e2e.ts new file mode 100644 index 000000000..48f343b7e --- /dev/null +++ b/tests/specs/paint-brush-size.e2e.ts @@ -0,0 +1,91 @@ +import { volViewPage } from '../pageobjects/volview.page'; +import { openVolViewPage, writeManifestToFile, writeMetaImage } from './utils'; + +const LARGE_IMAGE = 'paint-brush-large.mha'; +const SMALL_IMAGE = 'paint-brush-small.mha'; + +const dropOnSlot = (name: string, slotIndex: number) => + browser.execute( + (imageName, index) => { + const source = Array.from( + document.querySelectorAll('.image-list-card') + ).find((card) => card.textContent?.includes(imageName)); + const target = document.querySelectorAll('.grid-item')[index]; + if (!source || !target) return false; + const data = new DataTransfer(); + source.dispatchEvent( + new DragEvent('dragstart', { bubbles: true, dataTransfer: data }) + ); + if (!data.getData('application/x-volview-image-id')) return false; + target.dispatchEvent( + new DragEvent('dragenter', { bubbles: true, dataTransfer: data }) + ); + target.dispatchEvent( + new DragEvent('drop', { bubbles: true, dataTransfer: data }) + ); + return true; + }, + name, + slotIndex + ); + +describe('Paint brush size', () => { + it('keeps the smaller image preview when increasing a brush already at its maximum', async () => { + writeMetaImage(LARGE_IMAGE, { size: 70 }); + writeMetaImage(SMALL_IMAGE, { size: 21 }); + const manifest = 'paint-brush-size.json'; + await writeManifestToFile( + { + resources: [LARGE_IMAGE, SMALL_IMAGE].map((name) => ({ + url: `/tmp/${name}`, + })), + }, + manifest + ); + await openVolViewPage(manifest); + const largeCard = $(`.image-list-card*=${LARGE_IMAGE}`); + const smallCard = $(`.image-list-card*=${SMALL_IMAGE}`); + await largeCard.waitForClickable(); + await smallCard.waitForClickable(); + await largeCard.click(); + await expect($$('.grid-item')).toBeElementsArrayOfSize(4); + const slots = await $$('.grid-item'); + expect(await dropOnSlot(SMALL_IMAGE, 2)).toBe(true); + await browser.waitUntil(async () => + (await slots[2].getText()).includes(SMALL_IMAGE) + ); + + const largeCanvas = await slots[0].$('canvas'); + const smallCanvas = await slots[2].$('canvas'); + await largeCanvas.click(); + await volViewPage.activatePaint(); + await volViewPage.annotationsModuleTab.click(); + const size = $('[role="slider"][aria-label="Brush size"]'); + await expect(size).toHaveAttribute('aria-valuemax', '7'); + await browser.execute( + (element) => (element as HTMLElement).focus(), + await size + ); + await browser.keys('End'); + await expect(size).toHaveAttribute('aria-valuenow', '7'); + await largeCanvas.click(); + + const canvasImage = () => + browser.execute(async (element) => { + // The next frame drains scheduled renders; the following frame captures them. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); + return (element as HTMLCanvasElement).toDataURL(); + }, smallCanvas); + const withoutPreview = await canvasImage(); + await smallCanvas.moveTo(); + await browser.waitUntil( + async () => (await canvasImage()) !== withoutPreview + ); + const preview = await canvasImage(); + await browser.keys(']'); + await expect(size).toHaveAttribute('aria-valuenow', '7'); + expect((await canvasImage()) === preview).toBe(true); + }); +}); diff --git a/tests/specs/segment-controls-accessibility.e2e.ts b/tests/specs/segment-controls-accessibility.e2e.ts index 2918024c2..510aeed78 100644 --- a/tests/specs/segment-controls-accessibility.e2e.ts +++ b/tests/specs/segment-controls-accessibility.e2e.ts @@ -78,7 +78,7 @@ describe('Segment control accessibility', () => { 'Select the Paint tool to paint, erase or pick a segment' ); await browser.keys('Tab'); - const parameters = $('.paint-parameters'); + const parameters = $('[data-testid="paint-parameters"]'); await expect(parameters).toBeFocused(); const reason = await tooltipOf(parameters); await expect(reason).toBeDisplayed();