Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/components/tools/paint/PaintWidget2D.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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;
});

Expand All @@ -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(() => {
Expand Down
3 changes: 1 addition & 2 deletions src/composables/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
12 changes: 10 additions & 2 deletions src/core/tools/paint/ellipse-brush.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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,
Expand Down
172 changes: 172 additions & 0 deletions src/segmentation/__tests__/brushSize.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof activateAppPinia>;

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]);
});
});
Loading
Loading