Skip to content

Commit c95fd5a

Browse files
committed
fix(cine): fall back for unsupported multiframe formats
1 parent ee8edc1 commit c95fd5a

9 files changed

Lines changed: 413 additions & 29 deletions

File tree

src/components/PlayControls.vue

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,25 @@ const { slice, range } = useSliceConfig(viewId, imageId);
1818
1919
const playing = ref(false);
2020
const fps = ref(0);
21+
const fpsInput = ref('');
22+
23+
const MIN_FPS = 1;
24+
const MAX_FPS = 120;
25+
26+
function setFps(value: number) {
27+
fps.value = value;
28+
fpsInput.value = value > 0 ? String(value) : '';
29+
}
2130
2231
watch(
2332
cine,
2433
(image) => {
2534
if (!image) {
26-
fps.value = 0;
35+
setFps(0);
2736
return;
2837
}
2938
const frameTime = image.header.frameTimeMs;
30-
fps.value = frameTime && frameTime > 0 ? Math.round(1000 / frameTime) : 24;
39+
setFps(frameTime && frameTime > 0 ? Math.round(1000 / frameTime) : 24);
3140
},
3241
{ immediate: true }
3342
);
@@ -57,38 +66,54 @@ watch(imageId, () => {
5766
playing.value = false;
5867
});
5968
60-
const MIN_FPS = 1;
61-
const MAX_FPS = 120;
62-
6369
function togglePlay() {
6470
playing.value = !playing.value;
6571
}
6672
67-
function clampFps() {
68-
if (!Number.isFinite(fps.value) || fps.value < MIN_FPS) fps.value = MIN_FPS;
69-
else if (fps.value > MAX_FPS) fps.value = MAX_FPS;
70-
else fps.value = Math.round(fps.value);
73+
function clampFpsValue(value: string | number) {
74+
const parsed = Number(value);
75+
if (!Number.isFinite(parsed)) return null;
76+
return Math.min(MAX_FPS, Math.max(MIN_FPS, Math.round(parsed)));
77+
}
78+
79+
function clampFpsInput(event: Event) {
80+
const input = event.target as HTMLInputElement;
81+
fpsInput.value = input.value;
82+
if (fpsInput.value.trim() === '') return;
83+
84+
const clamped = clampFpsValue(fpsInput.value);
85+
if (clamped == null) return;
86+
setFps(clamped);
87+
input.value = fpsInput.value;
88+
}
89+
90+
function commitFpsInput() {
91+
const clamped = clampFpsValue(fpsInput.value);
92+
setFps(clamped ?? (fps.value > 0 ? fps.value : MIN_FPS));
7193
}
7294
</script>
7395

7496
<template>
75-
<div v-if="cine" class="play-controls pointer-events-all">
97+
<div v-if="cine" class="play-controls pointer-events-all" @dblclick.stop>
7698
<button
7799
type="button"
78100
class="play-btn"
79101
:title="playing ? 'Pause' : 'Play'"
102+
:aria-pressed="playing"
103+
:aria-label="playing ? 'Pause cine playback' : 'Play cine playback'"
80104
@click="togglePlay"
81105
>
82106
<v-icon size="14">{{ playing ? 'mdi-pause' : 'mdi-play' }}</v-icon>
83107
</button>
84108
<input
85-
v-model.number="fps"
109+
:value="fpsInput"
86110
type="number"
87111
:min="MIN_FPS"
88112
:max="MAX_FPS"
89113
class="fps-input"
90114
title="Frames per second"
91-
@change="clampFps"
115+
@input="clampFpsInput"
116+
@blur="commitFpsInput"
92117
/>
93118
<span class="fps-suffix">fps</span>
94119
</div>

src/components/tools/crosshairs/CrosshairsWidget2D.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useCrosshairsToolStore } from '@/src/store/tools/crosshairs';
1818
import { Maybe } from '@/src/types';
1919
import { VtkViewContext } from '@/src/components/vtk/context';
2020
import { useSliceInfo } from '@/src/composables/useSliceInfo';
21+
import { getRenderSlice } from '@/src/core/cine/getRenderSlice';
2122
2223
export default defineComponent({
2324
name: 'CrosshairsWidget2D',
@@ -64,7 +65,7 @@ export default defineComponent({
6465
updatePlaneManipulatorFor2DView(
6566
manipulator,
6667
viewDirection.value,
67-
slice.value,
68+
getRenderSlice(imageId.value, slice.value),
6869
imageMetadata.value
6970
);
7071
});

src/components/tools/paint/PaintWidget2D.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { VtkViewContext } from '@/src/components/vtk/context';
2626
import { Maybe } from '@/src/types';
2727
import { PaintMode } from '@/src/core/tools/paint';
2828
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
29+
import { getRenderSlice } from '@/src/core/cine/getRenderSlice';
2930
3031
export default defineComponent({
3132
name: 'PaintWidget2D',
@@ -138,7 +139,7 @@ export default defineComponent({
138139
updatePlaneManipulatorFor2DView(
139140
manipulator,
140141
viewDirection.value,
141-
slice.value,
142+
getRenderSlice(imageId.value, slice.value),
142143
imageMetadata.value
143144
);
144145
});

src/core/cine/DicomCineImage.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,10 @@ export default class DicomCineImage extends BaseProgressiveImage {
280280
if (isNativeTransferSyntax(header.transferSyntaxUID)) {
281281
// Native pixel data: only support 8-bit RGB or grayscale for v1.
282282
if (header.bitsAllocated !== 8) return false;
283-
if (header.samplesPerPixel !== 1 && header.samplesPerPixel !== 3) {
284-
return false;
285-
}
283+
const photometric = header.photometricInterpretation.trim();
284+
if (header.samplesPerPixel === 1) return photometric === 'MONOCHROME2';
285+
if (header.samplesPerPixel === 3) return photometric === 'RGB';
286+
return false;
286287
}
287288
return true;
288289
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import DicomCineImage from '../DicomCineImage';
4+
import type { CineHeader } from '../parseCineDicom';
5+
6+
const TS_EXPLICIT_VR_LE = '1.2.840.10008.1.2.1';
7+
const TS_JPEG_BASELINE = '1.2.840.10008.1.2.4.50';
8+
const TS_UNSUPPORTED = '1.2.840.10008.1.2.5';
9+
10+
function cineHeader(overrides: Partial<CineHeader> = {}): CineHeader {
11+
return {
12+
transferSyntaxUID: TS_EXPLICIT_VR_LE,
13+
rows: 2,
14+
cols: 2,
15+
numberOfFrames: 2,
16+
samplesPerPixel: 1,
17+
bitsAllocated: 8,
18+
planarConfiguration: 0,
19+
photometricInterpretation: 'MONOCHROME2',
20+
frameTimeMs: null,
21+
patient: {
22+
PatientID: 'patient-1',
23+
PatientName: 'Test Patient',
24+
PatientBirthDate: '',
25+
PatientSex: '',
26+
},
27+
study: {
28+
StudyID: 'study-1',
29+
StudyInstanceUID: 'study-uid',
30+
StudyDate: '',
31+
StudyTime: '',
32+
AccessionNumber: '',
33+
StudyDescription: '',
34+
},
35+
series: {
36+
SeriesInstanceUID: 'series-uid',
37+
SeriesNumber: '1',
38+
SeriesDescription: 'Cine',
39+
Modality: 'US',
40+
SOPInstanceUID: 'sop-uid',
41+
SOPClassUID: '1.2.840.10008.5.1.4.1.1.3.1',
42+
},
43+
regions: [],
44+
...overrides,
45+
};
46+
}
47+
48+
describe('DicomCineImage.isSupported', () => {
49+
it('accepts only MONOCHROME2 for native one-sample 8-bit pixels', () => {
50+
expect(DicomCineImage.isSupported(cineHeader())).toBe(true);
51+
52+
['MONOCHROME1', 'PALETTE COLOR', 'RGB', 'YBR_FULL', 'YBR_FULL_422'].forEach(
53+
(photometricInterpretation) => {
54+
expect(
55+
DicomCineImage.isSupported(cineHeader({ photometricInterpretation }))
56+
).toBe(false);
57+
}
58+
);
59+
});
60+
61+
it('accepts only RGB for native three-sample 8-bit pixels', () => {
62+
expect(
63+
DicomCineImage.isSupported(
64+
cineHeader({
65+
samplesPerPixel: 3,
66+
photometricInterpretation: 'RGB',
67+
})
68+
)
69+
).toBe(true);
70+
71+
[
72+
'MONOCHROME2',
73+
'MONOCHROME1',
74+
'PALETTE COLOR',
75+
'YBR_FULL',
76+
'YBR_FULL_422',
77+
].forEach((photometricInterpretation) => {
78+
expect(
79+
DicomCineImage.isSupported(
80+
cineHeader({
81+
samplesPerPixel: 3,
82+
photometricInterpretation,
83+
})
84+
)
85+
).toBe(false);
86+
});
87+
});
88+
89+
it('rejects native pixels unless BitsAllocated is 8', () => {
90+
expect(DicomCineImage.isSupported(cineHeader({ bitsAllocated: 16 }))).toBe(
91+
false
92+
);
93+
});
94+
95+
it('keeps encapsulated support scoped to transfer syntax acceptance', () => {
96+
expect(
97+
DicomCineImage.isSupported(
98+
cineHeader({
99+
transferSyntaxUID: TS_JPEG_BASELINE,
100+
bitsAllocated: 16,
101+
samplesPerPixel: 3,
102+
photometricInterpretation: 'YBR_FULL_422',
103+
})
104+
)
105+
).toBe(true);
106+
107+
expect(
108+
DicomCineImage.isSupported(
109+
cineHeader({
110+
transferSyntaxUID: TS_UNSUPPORTED,
111+
photometricInterpretation: 'RGB',
112+
})
113+
)
114+
).toBe(false);
115+
});
116+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { FrameCache, type DecodedFrame } from '../frameCache';
3+
4+
const MiB = 1024 * 1024;
5+
6+
function frame(byteLength: number): DecodedFrame {
7+
return {
8+
width: 1,
9+
height: 1,
10+
rgba: { byteLength } as Uint8ClampedArray,
11+
};
12+
}
13+
14+
describe('FrameCache', () => {
15+
it('defaults to a 64 MiB budget', () => {
16+
const cache = new FrameCache();
17+
18+
cache.set(0, frame(32 * MiB));
19+
cache.set(1, frame(32 * MiB));
20+
21+
expect(cache.has(0)).toBe(true);
22+
expect(cache.has(1)).toBe(true);
23+
expect(cache.getBytesInUse()).toBe(64 * MiB);
24+
25+
cache.set(2, frame(1));
26+
27+
expect(cache.has(0)).toBe(false);
28+
expect(cache.has(1)).toBe(true);
29+
expect(cache.has(2)).toBe(true);
30+
expect(cache.getBytesInUse()).toBe(32 * MiB + 1);
31+
});
32+
33+
it('refreshes entries on get before LRU eviction', () => {
34+
const cache = new FrameCache(10);
35+
36+
cache.set(0, frame(4));
37+
cache.set(1, frame(4));
38+
expect(cache.get(0)).not.toBeNull();
39+
cache.set(2, frame(4));
40+
41+
expect(cache.has(0)).toBe(true);
42+
expect(cache.has(1)).toBe(false);
43+
expect(cache.has(2)).toBe(true);
44+
});
45+
});

src/core/cine/frameCache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export type DecodedFrame = {
1111
rgba: Uint8ClampedArray;
1212
};
1313

14-
const DEFAULT_BUDGET_BYTES = 256 * 1024 * 1024;
14+
const DEFAULT_BUDGET_BYTES = 64 * 1024 * 1024;
1515

1616
export class FrameCache {
1717
private readonly budgetBytes: number;

0 commit comments

Comments
 (0)