Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 14 additions & 8 deletions src/io/itk-dicom/dicom.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,16 @@ std::vector<double> ReadImageOrientationValue(const std::string &filename) {
bool areCosinesAlmostEqual(std::vector<double> cosines1,
std::vector<double> cosines2,
double epsilon = EPSILON) {
for (int i = 0; i <= 1; i++) {
std::vector<double> vec1{cosines1.at(i), cosines1.at(i + 1),
cosines1.at(i + 2)};
std::vector<double> vec2{cosines2.at(i), cosines2.at(i + 1),
cosines2.at(i + 2)};
// ImageOrientationPatient is two row vectors: X cosines at [0..2] and
// Y cosines at [3..5]. Compare each row.
for (int row = 0; row < 2; row++) {
const int offset = row * 3;
std::vector<double> vec1{cosines1.at(offset), cosines1.at(offset + 1),
cosines1.at(offset + 2)};
std::vector<double> vec2{cosines2.at(offset), cosines2.at(offset + 1),
cosines2.at(offset + 2)};
double dot = dotProduct<3>(vec1, vec2);
if (dot < (1 - EPSILON)) {
if (dot < (1 - epsilon)) {
return false;
}
}
Expand All @@ -116,8 +119,6 @@ bool areCosinesAlmostEqual(std::vector<double> cosines1,

VolumeMapType SeparateOnImageOrientation(const VolumeMapType &volumeMap) {
VolumeMapType newVolumeMap;
// Vector< Pair< cosines, volumeID >>
std::vector<std::pair<std::vector<double>, std::string>> cosinesToID;

// append unique ID part to the volume ID, based on cosines
// The format replaces non-alphanumeric chars to be semi-consistent with DICOM
Expand All @@ -141,6 +142,11 @@ VolumeMapType SeparateOnImageOrientation(const VolumeMapType &volumeMap) {
};

for (const auto &[volumeID, names] : volumeMap) {
// Per-input-volume orientation lookup. Two distinct series sharing an
// orientation must remain separate, so this list is rebuilt per input
// volume rather than carried across them (issue #861).
std::vector<std::pair<std::vector<double>, std::string>> cosinesToID;

for (const auto &filename : names) {
std::vector<double> curCosines = ReadImageOrientationValue(filename);

Expand Down
Binary file modified src/io/itk-dicom/emscripten-build/dicom.wasm
Binary file not shown.
Binary file modified src/io/itk-dicom/emscripten-build/dicom.wasm.zst
Binary file not shown.
87 changes: 87 additions & 0 deletions tests/data/create-dicom-japanese-patient-name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Generate the Japanese-Patient-Name DICOM fixture for the dicom-japanese-name
e2e spec.

Run from the VolView repo root:

python3 tests/data/create-dicom-japanese-patient-name.py

Output goes to .tmp/ (the wdio static-server mount and shared dump folder),
which is gitignored.

The patient name (山田倍太郎) is encoded with Specific Character Set
"ISO 2022 IR 87" (JIS X 0208). Character 倍 has JIS code 0x475C, whose
trailing byte is 0x5C — the original "Unterminated string in JSON" failure
mode in #841 surfaces from that 0x5C leaking into JSON output.
"""

from pathlib import Path
import numpy as np
import pydicom
from pydicom.dataset import Dataset, FileDataset
from pydicom.uid import CTImageStorage, generate_uid

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
OUT_DIR = REPO_ROOT / ".tmp"
OUT = OUT_DIR / "dicom-japanese-patient-name.dcm"


def build() -> FileDataset:
file_meta = Dataset()
file_meta.MediaStorageSOPClassUID = CTImageStorage
file_meta.MediaStorageSOPInstanceUID = generate_uid()
file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian
file_meta.ImplementationClassUID = generate_uid()

ds = FileDataset(str(OUT), {}, file_meta=file_meta, preamble=b"\x00" * 128)

ds.SOPClassUID = CTImageStorage
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
ds.StudyInstanceUID = generate_uid()
ds.SeriesInstanceUID = generate_uid()
ds.Modality = "CT"

ds.SpecificCharacterSet = ["", "ISO 2022 IR 87"]
ds.PatientName = "山田倍太郎"
ds.PatientID = "TEST001"
ds.StudyDate = "20240101"
ds.StudyDescription = "Test"
ds.SeriesNumber = "1"
ds.InstanceNumber = "1"

ds.ImagePositionPatient = [0.0, 0.0, 0.0]
ds.ImageOrientationPatient = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
ds.PixelSpacing = [1.0, 1.0]
ds.SliceThickness = 1.0
ds.RescaleIntercept = -1024
ds.RescaleSlope = 1

ds.SamplesPerPixel = 1
ds.PhotometricInterpretation = "MONOCHROME2"
ds.Rows = 64
ds.Columns = 64
ds.BitsAllocated = 16
ds.BitsStored = 16
ds.HighBit = 15
ds.PixelRepresentation = 0

pixels = np.zeros((64, 64), dtype=np.uint16)
cy, cx = 32, 32
for i in range(64):
for j in range(64):
d = ((i - cy) ** 2 + (j - cx) ** 2) ** 0.5
pixels[i, j] = max(0, int(2000 - d * 30))
ds.PixelData = pixels.tobytes()

return ds


def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
ds = build()
ds.save_as(OUT, write_like_original=False)
print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)")


if __name__ == "__main__":
main()
27 changes: 27 additions & 0 deletions tests/specs/dicom-japanese-name.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as path from 'path';
import * as fs from 'fs';
import { TEMP_DIR } from '../../wdio.shared.conf';
import { volViewPage } from '../pageobjects/volview.page';
import { openVolViewPage } from './utils';

const FIXTURE_NAME = 'dicom-japanese-patient-name.dcm';

describe('DICOM with Japanese Patient Name (ISO 2022 IR 87)', () => {
before(() => {
const fixturePath = path.join(TEMP_DIR, FIXTURE_NAME);
if (!fs.existsSync(fixturePath)) {
throw new Error(
`Missing fixture ${fixturePath}. Generate it once with:\n` +
` python3 tests/data/create-dicom-japanese-patient-name.py`
);
}
});

it('should load without errors', async () => {
await openVolViewPage(FIXTURE_NAME);

const views = await volViewPage.views;
const viewCount = await views.length;
expect(viewCount).toBeGreaterThan(0);
});
});
74 changes: 74 additions & 0 deletions tests/specs/multi-series-load.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Regression for https://github.com/Kitware/VolView/issues/861
//
// Two DICOM series with distinct SeriesInstanceUIDs but identical
// ImageOrientationPatient must remain two volume cards, not collapse
// into one merged scan.
//
// Synthetic DICOMs are generated on the fly so the test has no external
// dependencies.
import * as path from 'path';
import * as fs from 'fs';
import { volViewPage } from '../pageobjects/volview.page';
import { TEMP_DIR } from '../../wdio.shared.conf';
import { buildSyntheticDicom, newUid } from './syntheticDicom';

// Oblique ImageOrientationPatient — same value for both series. With
// this orientation the pre-fix areCosinesAlmostEqual sees a non-zero
// second window and the cross-volume cosinesToID leak collapses both
// series into one.
const SHARED_IMAGE_ORIENTATION_PATIENT = [
-0.00964, 0.99248, 0.12202, 0.06932, 0.12239, -0.99006,
] as const;

const SLICE_COUNT = 5;
const STUDY_UID = newUid('study');

function writeSeries(label: 'A' | 'B', dirName: string, manifestName: string) {
const seriesUid = newUid(`series${label}`);
const dir = path.join(TEMP_DIR, dirName);
fs.mkdirSync(dir, { recursive: true });

const resources: { url: string; name: string }[] = [];
for (let i = 0; i < SLICE_COUNT; i++) {
const filename = `slice-${i}.dcm`;
const bytes = buildSyntheticDicom({
studyUid: STUDY_UID,
seriesUid,
sopUid: newUid(`sop${label}${i}`),
instanceNumber: i + 1,
imageOrientationPatient: SHARED_IMAGE_ORIENTATION_PATIENT,
// Step along the slice-normal so GDCM can sort within the series.
imagePositionPatient: [0, 0, i],
});
fs.writeFileSync(path.join(dir, filename), bytes);
resources.push({ url: `tmp/${dirName}/${filename}`, name: filename });
}

fs.writeFileSync(
path.join(TEMP_DIR, manifestName),
JSON.stringify({ resources })
);
}

describe('Multi-series load: two series with identical ImageOrientationPatient', () => {
before(() => {
writeSeries('A', 'multi-series-A', 'multi-series-A.json');
writeSeries('B', 'multi-series-B', 'multi-series-B.json');
});

it('keeps the two series separate (two volume cards)', async () => {
await volViewPage.open(
'?urls=[tmp/multi-series-A.json,tmp/multi-series-B.json]'
);
await volViewPage.waitForViews();
await browser.waitUntil(
async () => (await $$('.volume-card').length) >= 1,
{ timeout: 30000, timeoutMsg: 'no volume cards appeared' }
);
// Allow the second series's chunks to finish import.
await browser.pause(2000);

const cards = await $$('.volume-card');
expect(cards.length).toEqual(2);
});
});
Loading
Loading