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

Windowing from dicom tags #442

Merged
merged 20 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3b6d2e4
feat(datasets-dicom): Grab DICOM window width and level tags
bnmajor Oct 2, 2023
2d79a23
feat(WindowLevelControls): Provide auto-range options
bnmajor Oct 2, 2023
317193b
feat(WindowLevelControls): Add CT Preset options to the windowing menu
bnmajor Oct 2, 2023
73b7a82
feat(WindowLevelControls): List window width and level tags
bnmajor Oct 2, 2023
c2fa55f
feat(WindowLevelControls): Move preset options into collapsible panels
bnmajor Oct 3, 2023
5125a6a
fix(WindowLevelControls): Make sure default radio button is selected
bnmajor Oct 3, 2023
0ff669c
fix(VtkTwoView): Type autoRange computed property
bnmajor Oct 6, 2023
3632a21
fix(WindowLevelControls): Shrink radio buttons
bnmajor Oct 6, 2023
b4709e6
fix(VtkTwoView): Build histogram for determining percentiles
bnmajor Oct 9, 2023
f4f5d96
fix(constants): Full range of data should be the default
bnmajor Oct 9, 2023
3a682be
fix(WindowLevelControls): Only allow users to select one option
bnmajor Oct 9, 2023
d9d0198
feat(WindowLevelTool): Reset selection when width/level is changed
bnmajor Oct 9, 2023
4f08118
refactor(WindowLevelControls): List ct tags as const array
bnmajor Oct 9, 2023
f0a1e92
style(VtkTwoView): Remove outdated comment
bnmajor Oct 9, 2023
b864aa8
feat(WindowLevelControls): Hide presets panel if none exist
bnmajor Oct 13, 2023
bbbb1a0
feat(VtkTwoView): Use first W/L tag as default if available
bnmajor Oct 13, 2023
4e53978
refactor(WindowLevelControls): Move tags into their own panel
bnmajor Oct 13, 2023
3b35187
refactor(WindowLevelControls): Default preset and tags panel to open
bnmajor Oct 13, 2023
1d88dff
chore(tests): Update baseline images
bnmajor Oct 13, 2023
6ca7d71
feat(WindowLevelControls): Remove reset
bnmajor Oct 18, 2023
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
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