Skip to content

Commit

Permalink
Merge pull request #673 from PaulHax/overlapping-seg
Browse files Browse the repository at this point in the history
feat(segmentGroups): support overlapping segments in SEG files
  • Loading branch information
floryst authored Oct 24, 2024
2 parents ed8f158 + 4008dca commit 2652cfb
Show file tree
Hide file tree
Showing 9 changed files with 226 additions and 63 deletions.
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

0 comments on commit 2652cfb

Please sign in to comment.