Skip to content

Commit

Permalink
Merge pull request #442 from bnmajor/windowing-from-dicom-tags
Browse files Browse the repository at this point in the history
Windowing from dicom tags
  • Loading branch information
floryst authored Oct 23, 2023
2 parents 5d501d6 + 6ca7d71 commit b59bbae
Show file tree
Hide file tree
Showing 12 changed files with 399 additions and 31 deletions.
97 changes: 94 additions & 3 deletions src/components/VtkTwoView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ import BoundingRectangle from '@/src/components/tools/BoundingRectangle.vue';
import { useToolSelectionStore } from '@/src/store/tools/toolSelection';
import { useAnnotationToolStore } from '@/src/store/tools';
import { doesToolFrameMatchViewAxis } from '@/src/composables/annotationTool';
import { TypedArray } from '@kitware/vtk.js/types';
import { useResizeObserver } from '../composables/useResizeObserver';
import { useOrientationLabels } from '../composables/useOrientationLabels';
import { getLPSAxisFromDir } from '../utils/lps';
Expand Down Expand Up @@ -232,7 +233,13 @@ import useViewSliceStore, {
defaultSliceConfig,
} from '../store/view-configs/slicing';
import CropTool from './tools/crop/CropTool.vue';
import { ToolContainer, VTKTwoViewWidgetManager } from '../constants';
import {
ToolContainer,
VTKTwoViewWidgetManager,
WLAutoRanges,
WL_AUTO_DEFAULT,
WL_HIST_BINS,
} from '../constants';
import { useProxyManager } from '../composables/proxyManager';
import { getShiftedOpacityFromPreset } from '../utils/vtk-helpers';
import { useLayersStore } from '../store/datasets-layers';
Expand Down Expand Up @@ -329,6 +336,9 @@ export default defineComponent({
const windowWidth = computed(() => wlConfig.value?.width);
const windowLevel = computed(() => wlConfig.value?.level);
const autoRange = computed<keyof typeof WLAutoRanges>(
() => wlConfig.value?.auto || WL_AUTO_DEFAULT
);
const dicomInfo = computed(() => {
if (
curImageID.value !== null &&
Expand Down Expand Up @@ -455,26 +465,107 @@ export default defineComponent({
// --- window/level setup --- //
const histogram = (
data: number[] | TypedArray,
dataRange: number[],
numberOfBins: number
) => {
const [min, max] = dataRange;
const width = (max - min + 1) / numberOfBins;
const hist = new Array(numberOfBins).fill(0);
data.forEach((value) => hist[Math.floor((value - min) / width)]++);
return hist;
};
const autoRangeValues = computed(() => {
// Pre-compute the auto-range values
if (curImageData?.value) {
const scalarData = curImageData.value.getPointData().getScalars();
const [min, max] = scalarData.getRange();
const hist = histogram(scalarData.getData(), [min, max], WL_HIST_BINS);
const cumm = hist.reduce((acc, val, idx) => {
const prev = idx !== 0 ? acc[idx - 1] : 0;
acc.push(val + prev);
return acc;
}, []);
const width = (max - min + 1) / WL_HIST_BINS;
return Object.fromEntries(
Object.entries(WLAutoRanges).map(([key, value]) => {
const startIdx = cumm.findIndex(
(v: number) => v >= value * 0.01 * scalarData.getData().length
);
const endIdx = cumm.findIndex(
(v: number) =>
v >= (1 - value * 0.01) * scalarData.getData().length
);
const start = Math.max(min, min + width * startIdx);
const end = Math.min(max, min + width * endIdx + width);
return [key, [start, end]];
})
);
}
return {};
});
const firstTag = computed(() => {
if (
curImageID.value &&
curImageID.value in dicomStore.imageIDToVolumeKey
) {
const volKey = dicomStore.imageIDToVolumeKey[curImageID.value];
const { WindowWidth, WindowLevel } = dicomStore.volumeInfo[volKey];
return {
width: WindowWidth.split('\\')[0],
level: WindowLevel.split('\\')[0],
};
}
return {};
});
watch(
curImageData,
(imageData) => {
if (curImageID.value == null || wlConfig.value != null || !imageData) {
return;
}
// TODO listen to changes in point data
const range = imageData.getPointData().getScalars().getRange();
const range = autoRangeValues.value[autoRange.value];
windowingStore.updateConfig(viewID.value, curImageID.value, {
min: range[0],
max: range[1],
});
if (firstTag.value?.width) {
windowingStore.updateConfig(viewID.value, curImageID.value, {
preset: {
width: parseFloat(firstTag.value.width),
level: parseFloat(firstTag.value.level),
},
});
}
windowingStore.resetWindowLevel(viewID.value, curImageID.value);
},
{
immediate: true,
}
);
watch(autoRange, (percentile) => {
if (
curImageID.value == null ||
wlConfig.value == null ||
!curImageData.value
) {
return;
}
const range = autoRangeValues.value[percentile];
windowingStore.updateConfig(viewID.value, curImageID.value, {
min: range[0],
max: range[1],
});
windowingStore.resetWindowLevel(viewID.value, curImageID.value);
});
// --- scene setup --- //
const labelmapStore = useLabelmapStore();
Expand Down
218 changes: 194 additions & 24 deletions src/components/tools/windowing/WindowLevelControls.vue
Original file line number Diff line number Diff line change
@@ -1,47 +1,217 @@
<script lang="ts">
import { defineComponent } from 'vue';
import ToolButton from '@/src/components/ToolButton.vue';
import { computed, defineComponent, ref } from 'vue';
import { useCurrentImage } from '@/src/composables/useCurrentImage';
import useWindowingStore from '@/src/store/view-configs/windowing';
import useWindowingStore, {
defaultWindowLevelConfig,
} from '@/src/store/view-configs/windowing';
import { useViewStore } from '@/src/store/views';
import { WLAutoRanges, WLPresetsCT, WL_AUTO_DEFAULT } from '@/src/constants';
import { useDICOMStore } from '@/src/store/datasets-dicom';
export default defineComponent({
components: {
ToolButton,
},
setup() {
const { currentImageID } = useCurrentImage();
const windowingStore = useWindowingStore();
const viewStore = useViewStore();
const dicomStore = useDICOMStore();
const panel = ref(['tags', 'presets']);
const windowingDefaults = defaultWindowLevelConfig();
// Get the relevant view ids
const viewIDs = computed(() =>
viewStore.viewIDs.filter(
(viewID) => !!windowingStore.getConfig(viewID, currentImageID.value)
)
);
function parseLabel(text: string) {
return text.replace(/([A-Z])/g, ' $1').trim();
}
// --- CT Preset Options --- //
const modality = computed(() => {
if (
currentImageID.value &&
currentImageID.value in dicomStore.imageIDToVolumeKey
) {
const volKey = dicomStore.imageIDToVolumeKey[currentImageID.value];
const { Modality } = dicomStore.volumeInfo[volKey];
return Modality;
}
return '';
});
const isCT = computed(() => {
const ctTags = ['ct', 'ctprotocol'];
return modality.value && ctTags.includes(modality.value.toLowerCase());
});
const wlDefaults = computed(() => {
return { width: windowingDefaults.width, level: windowingDefaults.level };
});
// --- UI Selection Management --- //
type AutoRangeKey = keyof typeof WLAutoRanges;
type PresetValue = { width: number; level: number };
const resetWindowLevel = () => {
const wlConfig = computed(() => {
// All views will have the same settings, just grab the first
const viewID = viewIDs.value[0];
const imageID = currentImageID.value;
if (imageID) {
// Get the relevant view ids
const viewIDs = viewStore.viewIDs.filter(
(viewID) => !!windowingStore.getConfig(viewID, imageID)
);
// Reset the window/level for all views
viewIDs.map((viewID) =>
windowingStore.resetWindowLevel(viewID, imageID)
);
if (!imageID || !viewID) return windowingDefaults;
return windowingStore.getConfig(viewID, imageID);
});
const wlWidth = computed(
() => wlConfig.value?.width ?? wlDefaults.value.width
);
const wlLevel = computed(
() => wlConfig.value?.level ?? wlDefaults.value.level
);
const wlOptions = computed({
get() {
const config = wlConfig.value;
const { width, level } = config?.preset || wlDefaults.value;
const { width: defaultWidth, level: defaultLevel } = wlDefaults.value;
if (width !== defaultWidth && level !== defaultLevel) {
return { width: wlWidth.value, level: wlLevel.value };
}
return config?.auto || WL_AUTO_DEFAULT;
},
set(selection: AutoRangeKey | PresetValue) {
const imageID = currentImageID.value;
// All views will be synchronized, just set the first
const viewID = viewIDs.value[0];
if (imageID && viewID) {
const useAuto = typeof selection !== 'object';
const newValue = {
preset: useAuto ? wlDefaults.value : selection,
auto: useAuto ? selection : WL_AUTO_DEFAULT,
};
windowingStore.updateConfig(viewID, imageID, newValue);
windowingStore.resetWindowLevel(viewID, imageID);
}
},
});
// --- Tag WL Options --- //
function parseTags(text: string) {
return text.split('\\');
}
const tags = computed(() => {
if (
currentImageID.value &&
currentImageID.value in dicomStore.imageIDToVolumeKey
) {
const volKey = dicomStore.imageIDToVolumeKey[currentImageID.value];
const { WindowWidth, WindowLevel } = dicomStore.volumeInfo[volKey];
const levels = parseTags(WindowLevel);
return parseTags(WindowWidth).map((val, idx) => {
return { width: parseFloat(val), level: parseFloat(levels[idx]) };
});
}
};
return [];
});
return {
resetWindowLevel,
parseLabel,
wlOptions,
WLPresetsCT,
isCT,
tags,
panel,
WLAutoRanges,
};
},
});
</script>

<template>
<v-card dark>
<tool-button
size="40"
icon="mdi-restore"
name="Reset Window & Level"
@click="resetWindowLevel"
/>
<v-card-text>
<v-expansion-panels v-model="panel" multiple>
<v-expansion-panel value="tags" v-if="tags.length">
<v-expansion-panel-title>
Data-Specific Presets
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-radio-group v-model="wlOptions" hide-details>
<v-radio
v-for="(value, idx) in tags"
:key="idx"
:label="`Tag ${idx + 1} [W:${value.width},L:${value.level}]`"
:value="value"
density="compact"
/>
</v-radio-group>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="isCT" value="presets">
<v-expansion-panel-title>Presets</v-expansion-panel-title>
<v-expansion-panel-text>
<v-radio-group v-model="wlOptions" hide-details>
<template v-if="isCT">
<p>CT Presets</p>
<hr />
<div v-for="(options, category) in WLPresetsCT" :key="category">
<p>{{ parseLabel(category) }}</p>
<v-radio
v-for="(value, key) in options"
:key="key"
:label="parseLabel(key)"
:value="value"
density="compact"
class="ml-3"
/>
</div>
</template>
</v-radio-group>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel value="auto">
<v-expansion-panel-title>Auto Window/Level</v-expansion-panel-title>
<v-expansion-panel-text>
<v-radio-group v-model="wlOptions" hide-details>
<v-radio
v-for="(value, key) in WLAutoRanges"
:key="key"
:label="parseLabel(key)"
:value="key"
density="compact"
>
<v-tooltip activator="parent" location="bottom">
{{
value
? `Remove the top and bottom ${value} percent of data`
: 'Use the full data range'
}}
</v-tooltip>
</v-radio>
</v-radio-group>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</template>

<style scoped>
.v-card {
max-width: 300px;
}
.v-expansion-panel-title {
min-height: auto;
}
.v-expansion-panel-text:deep() .v-expansion-panel-text__wrapper {
padding: 4px 6px 8px;
}
.v-selection-control:deep() .v-selection-control__input > .v-icon {
font-size: 18px;
align-self: center;
}
</style>
Loading

0 comments on commit b59bbae

Please sign in to comment.