Skip to content

Commit da52ed7

Browse files
committed
refactor: move image min, max, autoRange to image-stats
1 parent bf22a03 commit da52ed7

6 files changed

Lines changed: 247 additions & 82 deletions

File tree

src/composables/useWindowingConfig.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import useWindowingStore from '@/src/store/view-configs/windowing';
22
import { Maybe } from '@/src/types';
33
import type { Vector2 } from '@kitware/vtk.js/types';
44
import { MaybeRef, unref, computed } from 'vue';
5+
import { useImageStatsStore } from '@/src/store/image-stats';
56

67
export function useWindowingConfig(
78
viewID: MaybeRef<string>,
89
imageID: MaybeRef<Maybe<string>>
910
) {
1011
const store = useWindowingStore();
12+
const imageStatsStore = useImageStatsStore();
1113
const config = computed(() => store.getConfig(unref(viewID), unref(imageID)));
1214

1315
const generateComputed = (prop: 'width' | 'level') => {
@@ -16,15 +18,23 @@ export function useWindowingConfig(
1618
set: (val) => {
1719
const imageIdVal = unref(imageID);
1820
if (!imageIdVal || val == null) return;
19-
store.updateConfig(unref(viewID), imageIdVal, { [prop]: val }, true);
21+
store.updateConfig(
22+
unref(viewID),
23+
imageIdVal,
24+
{ [prop]: val, useAuto: false },
25+
true
26+
);
2027
},
2128
});
2229
};
2330

2431
const range = computed((): Vector2 => {
25-
const { min, max } = config.value ?? {};
26-
if (min == null || max == null) return [0, 1];
27-
return [min, max];
32+
const imageIdVal = unref(imageID);
33+
if (!imageIdVal) return [0, 1];
34+
const stats = imageStatsStore.stats[imageIdVal];
35+
if (!stats || stats.scalarMin == null || stats.scalarMax == null)
36+
return [0, 1];
37+
return [stats.scalarMin, stats.scalarMax];
2838
});
2939

3040
return {

src/composables/useWindowingConfigInitializer.ts

Lines changed: 9 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,26 @@
11
import { MaybeRef, computed, unref, watch } from 'vue';
2-
import * as Comlink from 'comlink';
3-
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
4-
import { computedAsync, watchImmediate } from '@vueuse/core';
2+
import { watchImmediate } from '@vueuse/core';
53
import { useImage } from '@/src/composables/useCurrentImage';
64
import { useWindowingConfig } from '@/src/composables/useWindowingConfig';
7-
import { WLAutoRanges, WL_AUTO_DEFAULT, WL_HIST_BINS } from '@/src/constants';
5+
import { WL_AUTO_DEFAULT } from '@/src/constants';
86
import { getWindowLevels, useDICOMStore } from '@/src/store/datasets-dicom';
9-
import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef';
107
import useWindowingStore from '@/src/store/view-configs/windowing';
118
import { Maybe } from '@/src/types';
129
import { useResetViewsEvents } from '@/src/components/tools/ResetViews.vue';
1310
import { isDicomImage } from '@/src/utils/dataSelection';
14-
import { HistogramWorker } from '@/src/utils/histogram.worker';
15-
16-
function useAutoRangeValues(imageID: MaybeRef<Maybe<string>>) {
17-
const { imageData, isLoading: isImageLoading } = useImage(imageID);
18-
19-
const worker = Comlink.wrap<HistogramWorker>(
20-
new Worker(new URL('@/src/utils/histogram.worker.ts', import.meta.url), {
21-
type: 'module',
22-
})
23-
);
24-
25-
const scalars = vtkFieldRef(
26-
computed(() => imageData.value?.getPointData()),
27-
'scalars'
28-
);
29-
30-
const autoRangeValues = computedAsync(async () => {
31-
if (isImageLoading.value || !scalars.value) {
32-
return {};
33-
}
34-
35-
// Pre-compute the auto-range values
36-
const scalarData = scalars.value.getData();
37-
// Assumes all data is one component
38-
const { min, max } = vtkDataArray.fastComputeRange(
39-
scalarData as number[],
40-
0,
41-
1
42-
);
43-
const hist = await worker.histogram(scalarData, [min, max], WL_HIST_BINS);
44-
const cumulativeHist = hist.reduce((acc, val, idx) => {
45-
const prev = idx !== 0 ? acc[idx - 1] : 0;
46-
acc.push(val + prev);
47-
return acc;
48-
}, [] as number[]);
49-
50-
const width = (max - min + 1) / WL_HIST_BINS;
51-
return Object.fromEntries(
52-
Object.entries(WLAutoRanges).map(([key, value]) => {
53-
const startIdx = cumulativeHist.findIndex(
54-
(v: number) => v >= value * 0.01 * scalarData.length
55-
);
56-
const endIdx = cumulativeHist.findIndex(
57-
(v: number) => v >= (1 - value * 0.01) * scalarData.length
58-
);
59-
const start = Math.max(min, min + width * startIdx);
60-
const end = Math.min(max, min + width * endIdx + width);
61-
return [key, [start, end]];
62-
})
63-
);
64-
}, {});
65-
66-
return { autoRangeValues };
67-
}
11+
import { useImageStatsStore } from '@/src/store/image-stats';
6812

6913
export function useWindowingConfigInitializer(
7014
viewID: MaybeRef<string>,
7115
imageID: MaybeRef<Maybe<string>>
7216
) {
7317
const { imageData } = useImage(imageID);
7418
const dicomStore = useDICOMStore();
75-
76-
const scalarRange = vtkFieldRef(
77-
computed(() => imageData.value?.getPointData()?.getScalars()),
78-
'range'
79-
);
19+
const imageStatsStore = useImageStatsStore();
8020

8121
const store = useWindowingStore();
8222
const { config: windowConfig } = useWindowingConfig(viewID, imageID);
83-
const { autoRangeValues } = useAutoRangeValues(imageID);
23+
const autoRangeValues = imageStatsStore.getAutoRangeValues(imageID);
8424
const useAuto = computed(() => windowConfig.value?.useAuto);
8525
const autoRange = computed(() => windowConfig.value?.auto || WL_AUTO_DEFAULT);
8626

@@ -95,13 +35,6 @@ export function useWindowingConfigInitializer(
9535
return undefined;
9636
});
9737

98-
watchImmediate(scalarRange, (range) => {
99-
const imageIdVal = unref(imageID);
100-
const viewIdVal = unref(viewID);
101-
if (!range || !imageIdVal || !viewIdVal) return;
102-
store.updateConfig(viewIdVal, imageIdVal, { min: range[0], max: range[1] });
103-
});
104-
10538
function updateConfigFromAutoRangeValues() {
10639
const imageIdVal = unref(imageID);
10740
const viewIdVal = unref(viewID);
@@ -163,6 +96,10 @@ export function useWindowingConfigInitializer(
16396
return;
16497
}
16598
const range = autoRangeValues.value[autoRange.value];
99+
if (!range) {
100+
// This can happen during initial loading and range not computed yet.
101+
return;
102+
}
166103
const width = range[1] - range[0];
167104
const level = (range[1] + range[0]) / 2;
168105
store.updateConfig(viewIdVal, imageIdVal, {

src/io/state-file/schema.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,6 @@ type AutoRangeKeys = keyof typeof WLAutoRanges;
134134
const WindowLevelConfig = z.object({
135135
width: z.number(),
136136
level: z.number(),
137-
min: z.number(),
138-
max: z.number(),
139137
auto: z.string() as z.ZodType<AutoRangeKeys>,
140138
useAuto: z.boolean().optional(),
141139
userTriggered: z.boolean().optional(),

src/store/image-stats.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { defineStore } from 'pinia';
2+
import { reactive, watch, computed, MaybeRef, unref } from 'vue';
3+
import * as Comlink from 'comlink';
4+
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
5+
import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef';
6+
import { WLAutoRanges, WL_HIST_BINS } from '@/src/constants';
7+
import { HistogramWorker } from '@/src/utils/histogram.worker';
8+
import { Maybe } from '@/src/types';
9+
import { useImage } from '@/src/composables/useCurrentImage';
10+
import { useImageCacheStore } from './image-cache';
11+
import { useMessageStore } from './messages';
12+
13+
export type ImageStats = {
14+
scalarMin: number;
15+
scalarMax: number;
16+
autoRangeValues?: Record<string, [number, number]>;
17+
};
18+
19+
// Helper function to compute auto range values, similar to the original useAutoRangeValues
20+
async function computeAutoRangeValues(
21+
imageData: ReturnType<typeof useImage>['imageData']['value'],
22+
isImageLoading: ReturnType<typeof useImage>['isLoading']['value']
23+
): Promise<Record<string, [number, number]>> {
24+
if (isImageLoading || !imageData) {
25+
return {};
26+
}
27+
28+
const scalars = imageData.getPointData()?.getScalars();
29+
if (!scalars) {
30+
return {};
31+
}
32+
33+
const worker = Comlink.wrap<HistogramWorker>(
34+
new Worker(new URL('@/src/utils/histogram.worker.ts', import.meta.url), {
35+
type: 'module',
36+
})
37+
);
38+
39+
const scalarData = scalars.getData() as number[];
40+
const { min, max } = vtkDataArray.fastComputeRange(scalarData, 0, 1);
41+
const hist = await worker.histogram(scalarData, [min, max], WL_HIST_BINS);
42+
worker[Comlink.releaseProxy]();
43+
44+
const cumulativeHist: number[] = [];
45+
hist.reduce((acc, val) => {
46+
const currentSum = acc + val;
47+
cumulativeHist.push(currentSum);
48+
return currentSum;
49+
}, 0);
50+
51+
const width = (max - min + 1) / WL_HIST_BINS;
52+
const totalCount = scalarData.length;
53+
54+
return Object.fromEntries(
55+
Object.entries(WLAutoRanges).map(([key, percentage]) => {
56+
const lowerBound = percentage * 0.01 * totalCount;
57+
const upperBound = (1 - percentage * 0.01) * totalCount;
58+
59+
const startIdx = cumulativeHist.findIndex((v) => v >= lowerBound);
60+
const endIdx = cumulativeHist.findIndex((v) => v >= upperBound);
61+
62+
const start = Math.max(min, min + width * startIdx);
63+
const end = Math.min(max, min + width * (endIdx + 1)); // Adjusted end calculation
64+
return [key, [start, end] as [number, number]];
65+
})
66+
);
67+
}
68+
69+
export const useImageStatsStore = defineStore('image-stats', () => {
70+
const stats = reactive<Record<string, ImageStats>>({});
71+
const imageCacheStore = useImageCacheStore();
72+
const messageStore = useMessageStore();
73+
74+
const scalarRangeWatchers: Record<string, () => void> = {};
75+
const autoRangeComputations: Record<
76+
string,
77+
Promise<Record<string, [number, number]>>
78+
> = {};
79+
80+
const internalSetScalarRange = (
81+
imageID: string,
82+
min: number,
83+
max: number
84+
) => {
85+
stats[imageID] = {
86+
...stats[imageID], // preserve existing autoRangeValues
87+
scalarMin: min,
88+
scalarMax: max,
89+
};
90+
};
91+
92+
const internalSetAutoRangeValues = (
93+
imageID: string,
94+
autoValues: Record<string, [number, number]>
95+
) => {
96+
stats[imageID] = {
97+
...(stats[imageID] ?? { scalarMin: 0, scalarMax: 0 }),
98+
autoRangeValues: autoValues,
99+
};
100+
};
101+
102+
const internalRemoveStats = (imageID: string) => {
103+
delete stats[imageID];
104+
};
105+
106+
const setupImageWatcher = (id: string) => {
107+
if (scalarRangeWatchers[id]) {
108+
scalarRangeWatchers[id]();
109+
}
110+
111+
const { imageData, isLoading: isImageLoading } = useImage(
112+
computed(() => id)
113+
);
114+
115+
const activeScalars = computed(() =>
116+
imageData.value?.getPointData()?.getScalars()
117+
);
118+
const scalarRange = vtkFieldRef(activeScalars, 'range');
119+
120+
scalarRangeWatchers[id] = watch(
121+
scalarRange,
122+
(range) => {
123+
if (imageData.value && range) {
124+
internalSetScalarRange(id, range[0], range[1]);
125+
} else {
126+
internalRemoveStats(id);
127+
}
128+
},
129+
{ immediate: true }
130+
);
131+
132+
const updateAutoRangeValuesIfNeeded = () => {
133+
const currentImageData = imageData.value;
134+
const currentIsLoading = isImageLoading.value;
135+
136+
if (!currentIsLoading && currentImageData) {
137+
if (id in autoRangeComputations) {
138+
return;
139+
}
140+
autoRangeComputations[id] = computeAutoRangeValues(
141+
currentImageData,
142+
currentIsLoading
143+
);
144+
145+
autoRangeComputations[id]
146+
.then((autoValues) => {
147+
if (imageCacheStore.imageIds.includes(id)) {
148+
internalSetAutoRangeValues(id, autoValues);
149+
}
150+
})
151+
.catch((error) => {
152+
console.error(
153+
`[ImageStatsStore] Auto range computation for image ${id} FAILED:`,
154+
error
155+
);
156+
messageStore.addError(
157+
`Auto range computation failed for image ${id}`,
158+
error instanceof Error ? error : String(error)
159+
);
160+
if (imageCacheStore.imageIds.includes(id)) {
161+
internalSetAutoRangeValues(id, {});
162+
}
163+
})
164+
.finally(() => {
165+
delete autoRangeComputations[id];
166+
});
167+
} else if (!currentImageData) {
168+
internalSetAutoRangeValues(id, {});
169+
if (id in autoRangeComputations) {
170+
delete autoRangeComputations[id];
171+
}
172+
}
173+
};
174+
175+
watch(
176+
[imageData, isImageLoading],
177+
() => {
178+
updateAutoRangeValuesIfNeeded();
179+
},
180+
{ immediate: true, deep: false }
181+
);
182+
};
183+
184+
const cleanupImageWatcher = (id: string) => {
185+
internalRemoveStats(id);
186+
if (scalarRangeWatchers[id]) {
187+
scalarRangeWatchers[id]();
188+
delete scalarRangeWatchers[id];
189+
}
190+
delete autoRangeComputations[id];
191+
};
192+
193+
watch(
194+
() => [...imageCacheStore.imageIds],
195+
(currentImageIds, previousImageIds = []) => {
196+
const addedIds = currentImageIds.filter(
197+
(id) => !previousImageIds.includes(id)
198+
);
199+
const removedIds = previousImageIds.filter(
200+
(id) => !currentImageIds.includes(id)
201+
);
202+
203+
removedIds.forEach(cleanupImageWatcher);
204+
addedIds.forEach(setupImageWatcher);
205+
},
206+
{ immediate: true }
207+
);
208+
209+
// Getter for autoRangeValues, returning a computed ref
210+
const getAutoRangeValues = (imageID: MaybeRef<Maybe<string>>) => {
211+
return computed(() => {
212+
const id = unref(imageID); // Use unref to get value from MaybeRef
213+
if (id && stats[id]) {
214+
return stats[id].autoRangeValues ?? {};
215+
}
216+
return {};
217+
});
218+
};
219+
220+
return {
221+
stats,
222+
getAutoRangeValues, // Expose the getter
223+
};
224+
});

0 commit comments

Comments
 (0)