Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(segmentGroups): support overlapping segments in SEG files #673

Merged
merged 2 commits into from
Oct 24, 2024
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
7 changes: 4 additions & 3 deletions src/components/SegmentGroupControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ function openSaveDialog(id: string) {
</script>

<template>
<div class="my-2" v-if="currentImageID">
<div class="mt-2" v-if="currentImageID">
<div
class="text-grey text-subtitle-2 d-flex align-center justify-space-evenly mb-2"
>
Expand Down Expand Up @@ -203,11 +203,12 @@ function openSaveDialog(id: string) {
</v-list>
</v-menu>
</div>
<v-divider />
<v-divider class="my-4" />

<segment-group-opacity
v-if="currentSegmentGroupID"
:group-id="currentSegmentGroupID"
class="my-1"
/>
<v-radio-group
v-model="currentSegmentGroupID"
Expand Down Expand Up @@ -260,7 +261,7 @@ function openSaveDialog(id: string) {
</template>
</v-radio>
</v-radio-group>
<v-divider />
<v-divider class="my-4" />
</div>
<div v-else class="text-center text-caption">No selected image</div>
<segment-list
Expand Down
2 changes: 1 addition & 1 deletion src/components/SegmentGroupOpacity.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const setOpacity = (opacity: number) => {

<template>
<v-slider
class="ma-4"
class="mx-4"
label="Segment Group Opacity"
min="0"
max="1"
Expand Down
5 changes: 3 additions & 2 deletions src/components/SegmentList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ function deleteEditingSegment() {
</script>

<template>
<v-btn @click.stop="toggleGlobalVisible">
<v-btn @click.stop="toggleGlobalVisible" class="my-1">
Toggle Segments
<slot name="append">
<v-icon v-if="allVisible" class="pl-2">mdi-eye</v-icon>
Expand All @@ -171,7 +171,7 @@ function deleteEditingSegment() {
</v-btn>

<v-slider
class="ma-4"
class="mx-4 my-1"
label="Segment Opacity"
min="0"
max="1"
Expand All @@ -190,6 +190,7 @@ function deleteEditingSegment() {
item-title="name"
create-label-text="New segment"
@create="addNewSegment"
class="my-4"
>
<template #item-prepend="{ item }">
<!-- dot container keeps overflowing name from squishing dot width -->
Expand Down
2 changes: 1 addition & 1 deletion src/io/dicom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ type ReadOverlappingSegmentationResultWithRealMeta =
metaInfo: ReadOverlappingSegmentationMeta;
};

export async function buildLabelMap(file: File) {
export async function buildSegmentGroups(file: File) {
const inputImage = sanitizeFile(file);
const result = (await readOverlappingSegmentation(inputImage, {
webWorker: getWorker(),
Expand Down
11 changes: 2 additions & 9 deletions src/store/datasets-dicom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { defineStore } from 'pinia';
import { Image } from 'itk-wasm';
import { DataSourceWithFile } from '@/src/io/import/dataSource';
import * as DICOM from '@/src/io/dicom';
import { pullComponent0 } from '@/src/utils/images';
import { identity, pick, removeFromArray } from '../utils';
import { useImageStore } from './datasets-images';
import { useFileStore } from './datasets-files';
Expand Down Expand Up @@ -55,16 +54,10 @@ const buildImage = async (seriesFiles: File[], modality: string) => {
const messages: string[] = [];
if (modality === 'SEG') {
const segFile = seriesFiles[0];
const results = await DICOM.buildLabelMap(segFile);
if (results.outputImage.imageType.components !== 1) {
messages.push(
`${segFile.name} SEG file has overlapping segments. Using first set.`
);
results.outputImage = pullComponent0(results.segImage);
}
const results = await DICOM.buildSegmentGroups(segFile);
if (seriesFiles.length > 1)
messages.push(
'SEG image has multiple components. Using only the first component.'
'Tried to make one volume from 2 SEG modality files. Using only the first file!'
);
return {
modality: 'SEG',
Expand Down
99 changes: 73 additions & 26 deletions src/store/segmentGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getImage,
isRegularImage,
} from '@/src/utils/dataSelection';
import vtkImageExtractComponents from '@/src/utils/imageExtractComponentsFilter';
import vtkLabelMap from '../vtk/LabelMap';
import {
StateFile,
Expand All @@ -35,6 +36,7 @@ export const DEFAULT_SEGMENT_COLOR: RGBAColor = [255, 0, 0, 255];
export const makeDefaultSegmentName = (value: number) => `Segment ${value}`;
export const makeDefaultSegmentGroupName = (baseName: string, index: number) =>
`Segment Group ${index} for ${baseName}`;
const numberer = (index: number) => (index <= 1 ? '' : `${index}`); // start numbering at 2

export interface SegmentGroupMetadata {
name: string;
Expand Down Expand Up @@ -79,6 +81,20 @@ export function toLabelMap(imageData: vtkImageData) {
return labelmap;
}

export function extractEachComponent(input: vtkImageData) {
const numComponents = input
.getPointData()
.getScalars()
.getNumberOfComponents();
const extractComponentsFilter = vtkImageExtractComponents.newInstance();
extractComponentsFilter.setInputData(input);
return Array.from({ length: numComponents }, (_, i) => {
extractComponentsFilter.setComponents([i]);
extractComponentsFilter.update();
return extractComponentsFilter.getOutputData() as vtkImageData;
});
}

export const useSegmentGroupStore = defineStore('segmentGroup', () => {
type _This = ReturnType<typeof useSegmentGroupStore>;

Expand Down Expand Up @@ -156,6 +172,22 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
});
});

function pickUniqueName(
formatName: (index: number) => string,
parentID: string
) {
const existingNames = new Set(
Object.values(metadataByID).map((meta) => meta.name)
);
let name = '';
do {
const nameIndex = nextDefaultIndex[parentID] ?? 1;
nextDefaultIndex[parentID] = nameIndex + 1;
name = formatName(nameIndex);
} while (existingNames.has(name));
return name;
}

/**
* Creates a new labelmap entry from a parent/source image.
*/
Expand All @@ -174,16 +206,10 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
'value'
);

// pick a unique name
let name = '';
const existingNames = new Set(
Object.values(metadataByID).map((meta) => meta.name)
const name = pickUniqueName(
(index: number) => makeDefaultSegmentGroupName(baseName, index),
parentID
);
do {
const nameIndex = nextDefaultIndex[parentID] ?? 1;
nextDefaultIndex[parentID] = nameIndex + 1;
name = makeDefaultSegmentGroupName(baseName, nameIndex);
} while (existingNames.has(name));

return addLabelmap.call(this, labelmap, {
name,
Expand All @@ -210,15 +236,21 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
return [...color, 255];
}

async function decodeSegments(imageId: DataSelection, image: vtkLabelMap) {
async function decodeSegments(
imageId: DataSelection,
image: vtkLabelMap,
component = 0
) {
if (!isRegularImage(imageId)) {
// dicom image
const dicomStore = useDICOMStore();

const volumeBuildResults = await dicomStore.volumeBuildResults[imageId];
if (volumeBuildResults.modality === 'SEG') {
const segments =
volumeBuildResults.builtImageResults.metaInfo.segmentAttributes[0];
volumeBuildResults.builtImageResults.metaInfo.segmentAttributes[
component
];
return segments.map((segment) => ({
value: segment.labelID,
name: segment.SegmentLabel,
Expand Down Expand Up @@ -272,27 +304,42 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
);
}

const name = imageStore.metadata[imageID].name;
// Don't remove image if DICOM as user may have selected segment group image as primary selection by now
// Don't remove image if DICOM. User may have selected segment group image as primary selection by now
const deleteImage = isRegularImage(imageID);
if (deleteImage) {
imageStore.deleteData(imageID);
}

const matchingParentSpace = await ensureSameSpace(
parentImage,
childImage,
true
);
const labelmapImage = toLabelMap(matchingParentSpace);
const componentCount = childImage
.getPointData()
.getScalars()
.getNumberOfComponents();
// for each component, create create new vtkImageData with just one component, pulled from each component of childImage
const images =
componentCount === 1 ? [childImage] : extractEachComponent(childImage);

const baseName = imageStore.metadata[imageID].name;
images.forEach(async (image, component) => {
const matchingParentSpace = await ensureSameSpace(
parentImage,
image,
true
);
const labelmapImage = toLabelMap(matchingParentSpace);

const segments = await decodeSegments(imageID, labelmapImage);
const { order, byKey } = normalizeForStore(segments, 'value');
const segmentGroupStore = useSegmentGroupStore();
segmentGroupStore.addLabelmap(labelmapImage, {
name,
parentImage: parentID,
segments: { order, byValue: byKey },
const segments = await decodeSegments(imageID, labelmapImage, component);
const { order, byKey } = normalizeForStore(segments, 'value');
const segmentGroupStore = useSegmentGroupStore();

const name = pickUniqueName(
(index: number) => `${baseName} ${numberer(index)}`,
parentID
);
segmentGroupStore.addLabelmap(labelmapImage, {
name,
parentImage: parentID,
segments: { order, byValue: byKey },
});
});
}

Expand Down
56 changes: 56 additions & 0 deletions src/utils/__tests__/imageExtractComponentsFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import vtkImageExtractComponentsFilter from '../imageExtractComponentsFilter';

describe('vtkImageExtractComponentsFilter', () => {
it('should extract specified components', () => {
// Create an image data with known scalar components
const imageData = vtkImageData.newInstance();
imageData.setDimensions([2, 2, 1]);

// Create scalar data with 3 components per voxel
const scalars = vtkDataArray.newInstance({
numberOfComponents: 3,
values: new Uint8Array([
// Voxel 0
10, 20, 30,
// Voxel 1
40, 50, 60,
// Voxel 2
70, 80, 90,
// Voxel 3
100, 110, 120,
]),
});

imageData.getPointData().setScalars(scalars);

// Create the filter and set components to extract
const extractComponentsFilter =
vtkImageExtractComponentsFilter.newInstance();
extractComponentsFilter.setComponents([0, 2]); // Extract components 0 and 2
extractComponentsFilter.setInputData(imageData);
extractComponentsFilter.update();

const outputData = extractComponentsFilter.getOutputData();
const outputScalars = outputData.getPointData().getScalars();
const outputValues = outputScalars.getData();

// Expected output
const expectedValues = new Uint8Array([
// Voxel 0
10, 30,
// Voxel 1
40, 60,
// Voxel 2
70, 90,
// Voxel 3
100, 120,
]);

// Check if output matches expected values
expect(outputScalars.getNumberOfComponents()).toBe(2);
expect(outputValues).toEqual(expectedValues);
});
});
Loading
Loading