Skip to content

Commit ad628e1

Browse files
committed
WIP feat(image-sets-normalization): add group by options
Also, remove import of "itkGDCMImageIO.h" by extracting CharStringToUTF8Converter
1 parent 35db8a3 commit ad628e1

15 files changed

+655
-457
lines changed

packages/dicom/gdcm/CharStringToUTF8Converter.h

+467
Large diffs are not rendered by default.

packages/dicom/gdcm/DICOMTagReader.h

+1-439
Large diffs are not rendered by default.

packages/dicom/gdcm/Tags.h

-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020

2121
#include <string>
2222
#include <set>
23-
#include "itkGDCMImageIO.h"
2423

2524
using Tag = gdcm::Tag;
2625
using Tags = std::set<Tag>;
+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*=========================================================================
2+
*
3+
* Copyright NumFOCUS
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0.txt
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*=========================================================================*/
18+
#ifndef TAGS_OPTION_PARSER_H
19+
#define TAGS_OPTION_PARSER_H
20+
21+
#include "rapidjson/document.h"
22+
23+
#include "Tags.h"
24+
25+
std::optional<Tags> parseTags(itk::wasm::InputTextStream &tagsToRead, itk::wasm::Pipeline &pipeline)
26+
{
27+
if (tagsToRead.GetPointer() == nullptr)
28+
{
29+
return std::nullopt;
30+
}
31+
32+
rapidjson::Document inputTagsDocument;
33+
const std::string inputTagsString((std::istreambuf_iterator<char>(tagsToRead.Get())),
34+
std::istreambuf_iterator<char>());
35+
if (inputTagsDocument.Parse(inputTagsString.c_str()).HasParseError())
36+
{
37+
CLI::Error err("Runtime error", "Could not parse input tags JSON.", 1);
38+
pipeline.exit(err);
39+
return std::nullopt;
40+
}
41+
if (!inputTagsDocument.HasMember("tags"))
42+
{
43+
CLI::Error err("Runtime error", "Input tags does not have expected \"tags\" member", 1);
44+
pipeline.exit(err);
45+
return std::nullopt;
46+
}
47+
48+
const rapidjson::Value &inputTagsArray = inputTagsDocument["tags"];
49+
50+
Tags tags;
51+
for (rapidjson::Value::ConstValueIterator itr = inputTagsArray.Begin(); itr != inputTagsArray.End(); ++itr)
52+
{
53+
const std::string tagString(itr->GetString());
54+
Tag tag;
55+
tag.ReadFromPipeSeparatedString(tagString.c_str());
56+
tags.insert(tag);
57+
}
58+
return tags;
59+
}
60+
61+
#endif // TAGS_OPTION_PARSER_H

packages/dicom/gdcm/image-sets-normalization.cxx

+24-13
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,18 @@
4343
#include "itkMakeUniqueForOverwrite.h"
4444

4545
#include "itkPipeline.h"
46+
#include "itkInputTextStream.h"
4647
#include "itkOutputTextStream.h"
4748

48-
#include "DICOMTagReader.h"
49+
#include "CharStringToUTF8Converter.h"
4950
#include "Tags.h"
51+
#include "TagsOptionParser.h"
5052
#include "SortSpatially.h"
5153

5254
std::string getLabelFromTag(const gdcm::Tag &tag, const gdcm::DataSet &dataSet)
5355
{
54-
if (tag.IsPrivateCreator()) {
56+
if (tag.IsPrivateCreator())
57+
{
5558
return tag.PrintAsContinuousUpperCaseString();
5659
}
5760
std::string strowner;
@@ -529,7 +532,6 @@ using DicomFiles = std::unordered_set<DicomFile, dicomFileHash>;
529532
DicomFiles loadFiles(const std::vector<FileName> &fileNames)
530533
{
531534
DicomFiles dicomFiles;
532-
itk::DICOMTagReader tagReader;
533535
for (const FileName &fileName : fileNames)
534536
{
535537
dicomFiles.insert(DicomFile(fileName));
@@ -559,20 +561,19 @@ bool compareTags(const gdcm::DataSet &tagsA, const gdcm::DataSet &tagsB, const T
559561
return true;
560562
}
561563

562-
bool isSameVolume(const gdcm::DataSet &tagsA, const gdcm::DataSet &tagsB)
564+
bool isSameVolume(const gdcm::DataSet &tagsA, const gdcm::DataSet &tagsB, const Tags &criteria)
563565
{
564-
const Tags criteria = {SERIES_UID, FRAME_OF_REFERENCE_UID};
565566
return compareTags(tagsA, tagsB, criteria);
566567
}
567568

568-
Volumes groupByVolume(const DicomFiles &dicomFiles)
569+
Volumes groupByVolume(const DicomFiles &dicomFiles, const Tags &criteria = {SERIES_UID, FRAME_OF_REFERENCE_UID})
569570
{
570571
Volumes volumes;
571572
for (const DicomFile &dicomFile : dicomFiles)
572573
{
573574
const auto candidate = dicomFile.dataSet;
574-
auto matchingVolume = std::find_if(volumes.begin(), volumes.end(), [&candidate](const Volume &volume)
575-
{ return isSameVolume(volume.begin()->dataSet, candidate); });
575+
auto matchingVolume = std::find_if(volumes.begin(), volumes.end(), [&candidate, &criteria](const Volume &volume)
576+
{ return isSameVolume(volume.begin()->dataSet, candidate, criteria); });
576577

577578
if (matchingVolume != volumes.end())
578579
{
@@ -587,16 +588,16 @@ Volumes groupByVolume(const DicomFiles &dicomFiles)
587588
return volumes;
588589
}
589590

590-
ImageSets groupByImageSet(const Volumes &volumes)
591+
ImageSets groupByImageSet(const Volumes &volumes, const Tags &imageSetCriteria = {STUDY_UID})
591592
{
592593
ImageSets imageSets;
593594
for (const Volume &volume : volumes)
594595
{
595596
const gdcm::DataSet volumeDataSet = volume.begin()->dataSet;
596-
auto matchingImageSet = std::find_if(imageSets.begin(), imageSets.end(), [&volumeDataSet](const Volumes &volumes)
597+
auto matchingImageSet = std::find_if(imageSets.begin(), imageSets.end(), [&volumeDataSet, &imageSetCriteria](const Volumes &volumes)
597598
{
598599
const gdcm::DataSet imageSetDataSet = volumes.begin()->begin()->dataSet;
599-
return compareTags(volumeDataSet, imageSetDataSet, {STUDY_UID}); });
600+
return compareTags(volumeDataSet, imageSetDataSet, imageSetCriteria); });
600601
if (matchingImageSet != imageSets.end())
601602
{
602603
matchingImageSet->push_back(volume);
@@ -730,15 +731,25 @@ int main(int argc, char *argv[])
730731
std::vector<std::string> files;
731732
pipeline.add_option("--files", files, "DICOM files")->required()->check(CLI::ExistingFile)->type_size(1, -1)->type_name("INPUT_BINARY_FILE");
732733

734+
itk::wasm::InputTextStream seriesGroupByOption;
735+
pipeline.add_option("--series-group-by", seriesGroupByOption, "Create series so that all instances in a series share these tags. Option is a JSON object with a \"tags\" array. Example tag: \"0008|103e\". If not provided, defaults to Series UID and Frame Of Reference UID tags.")->type_name("INPUT_JSON");
736+
itk::wasm::InputTextStream imageSetGroupByOption;
737+
pipeline.add_option("--image-set-group-by", imageSetGroupByOption, "Create image sets so that all series in a set share these tags. Option is a JSON object with a \"tags\" array. Example tag: \"0008|103e\". If not provided, defaults to Study UID tag.")->type_name("INPUT_JSON");
738+
733739
itk::wasm::OutputTextStream imageSetsOutput;
734740
pipeline.add_option("image-sets", imageSetsOutput, "Image sets JSON")->required()->type_name("OUTPUT_JSON");
735741

736742
ITK_WASM_PARSE(pipeline);
737743

744+
const std::optional<Tags> seriesGroupByParse = parseTags(seriesGroupByOption, pipeline);
745+
const Tags seriesGroupBy = seriesGroupByParse.value_or(Tags{SERIES_UID, FRAME_OF_REFERENCE_UID});
746+
const std::optional<Tags> imageSetGroupByParse = parseTags(imageSetGroupByOption, pipeline);
747+
const Tags imageSetGroupBy = imageSetGroupByParse.value_or(Tags{STUDY_UID});
748+
738749
const DicomFiles dicomFiles = loadFiles(files);
739-
Volumes volumes = groupByVolume(dicomFiles);
750+
Volumes volumes = groupByVolume(dicomFiles, seriesGroupBy);
740751
volumes = sortSpatially(volumes);
741-
const ImageSets imageSets = groupByImageSet(volumes);
752+
const ImageSets imageSets = groupByImageSet(volumes, imageSetGroupBy);
742753

743754
rapidjson::Document imageSetsJson = toJson(imageSets);
744755
rapidjson::StringBuffer stringBuffer;

packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/image_sets_normalization_async.py

+12
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,20 @@
1818

1919
async def image_sets_normalization_async(
2020
files: List[os.PathLike] = [],
21+
series_group_by: Optional[Any] = None,
22+
image_set_group_by: Optional[Any] = None,
2123
) -> Any:
2224
"""Group DICOM files into image sets
2325
2426
:param files: DICOM files
2527
:type files: os.PathLike
2628
29+
:param series_group_by: Create series so that all instances in a series share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Series UID and Frame Of Reference UID tags.
30+
:type series_group_by: Any
31+
32+
:param image_set_group_by: Create image sets so that all series in a set share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Study UID tag.
33+
:type image_set_group_by: Any
34+
2735
:return: Image sets JSON
2836
:rtype: Any
2937
"""
@@ -33,6 +41,10 @@ async def image_sets_normalization_async(
3341
kwargs = {}
3442
if files is not None:
3543
kwargs["files"] = to_js(BinaryFile(files))
44+
if series_group_by is not None:
45+
kwargs["seriesGroupBy"] = to_js(series_group_by)
46+
if image_set_group_by is not None:
47+
kwargs["imageSetGroupBy"] = to_js(image_set_group_by)
3648

3749
outputs = await js_module.imageSetsNormalization(webWorker=web_worker, noCopy=True, **kwargs)
3850

packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/image_sets_normalization.py

+20
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,20 @@
1818

1919
def image_sets_normalization(
2020
files: List[os.PathLike] = [],
21+
series_group_by: Optional[Any] = None,
22+
image_set_group_by: Optional[Any] = None,
2123
) -> Any:
2224
"""Group DICOM files into image sets
2325
2426
:param files: DICOM files
2527
:type files: os.PathLike
2628
29+
:param series_group_by: Create series so that all instances in a series share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Series UID and Frame Of Reference UID tags.
30+
:type series_group_by: Any
31+
32+
:param image_set_group_by: Create image sets so that all series in a set share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Study UID tag.
33+
:type image_set_group_by: Any
34+
2735
:return: Image sets JSON
2836
:rtype: Any
2937
"""
@@ -55,6 +63,18 @@ def image_sets_normalization(
5563
pipeline_inputs.append(PipelineInput(InterfaceTypes.BinaryFile, BinaryFile(value)))
5664
args.append(input_file)
5765

66+
if series_group_by is not None:
67+
pipeline_inputs.append(PipelineInput(InterfaceTypes.JsonCompatible, series_group_by))
68+
args.append('--series-group-by')
69+
args.append(str(input_count))
70+
input_count += 1
71+
72+
if image_set_group_by is not None:
73+
pipeline_inputs.append(PipelineInput(InterfaceTypes.JsonCompatible, image_set_group_by))
74+
args.append('--image-set-group-by')
75+
args.append(str(input_count))
76+
input_count += 1
77+
5878

5979
outputs = _pipeline.run(args, pipeline_outputs, pipeline_inputs)
6080

packages/dicom/python/itkwasm-dicom-wasi/tests/test_image_sets_normalization.py

+8
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def test_ct():
5252
sorted_files = pick_files(image_sets[0])
5353
assert_equal(sorted_files, ct_series)
5454

55+
5556
def test_mr():
5657
assert mr_series[0].exists()
5758
out_of_order = [
@@ -67,6 +68,13 @@ def test_mr():
6768
assert_equal(sorted_files, mr_series)
6869

6970

71+
def test_series_group_by_option():
72+
assert mr_series[0].exists()
73+
group_by_tags = {"tags": ["0008|0018"]} # SOP Instance UID
74+
image_sets = image_sets_normalization(mr_series, series_group_by=group_by_tags)
75+
assert len(image_sets) == len(mr_series)
76+
77+
7078
def test_two_series():
7179
files = [
7280
orientation_series[1],

packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,23 @@
1010

1111
def image_sets_normalization(
1212
files: List[os.PathLike] = [],
13+
series_group_by: Optional[Any] = None,
14+
image_set_group_by: Optional[Any] = None,
1315
) -> Any:
1416
"""Group DICOM files into image sets
1517
1618
:param files: DICOM files
1719
:type files: os.PathLike
1820
21+
:param series_group_by: Create series so that all instances in a series share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Series UID and Frame Of Reference UID tags.
22+
:type series_group_by: Any
23+
24+
:param image_set_group_by: Create image sets so that all series in a set share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Study UID tag.
25+
:type image_set_group_by: Any
26+
1927
:return: Image sets JSON
2028
:rtype: Any
2129
"""
2230
func = environment_dispatch("itkwasm_dicom", "image_sets_normalization")
23-
output = func(files=files)
31+
output = func(files=files, series_group_by=series_group_by, image_set_group_by=image_set_group_by)
2432
return output

packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization_async.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,23 @@
1010

1111
async def image_sets_normalization_async(
1212
files: List[os.PathLike] = [],
13+
series_group_by: Optional[Any] = None,
14+
image_set_group_by: Optional[Any] = None,
1315
) -> Any:
1416
"""Group DICOM files into image sets
1517
1618
:param files: DICOM files
1719
:type files: os.PathLike
1820
21+
:param series_group_by: Create series so that all instances in a series share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Series UID and Frame Of Reference UID tags.
22+
:type series_group_by: Any
23+
24+
:param image_set_group_by: Create image sets so that all series in a set share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Study UID tag.
25+
:type image_set_group_by: Any
26+
1927
:return: Image sets JSON
2028
:rtype: Any
2129
"""
2230
func = environment_dispatch("itkwasm_dicom", "image_sets_normalization_async")
23-
output = await func(files=files)
31+
output = await func(files=files, series_group_by=series_group_by, image_set_group_by=image_set_group_by)
2432
return output
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
// Generated file. To retain edits, remove this comment.
22

3-
import { BinaryFile } from 'itk-wasm'
3+
import { BinaryFile,JsonCompatible } from 'itk-wasm'
44

55
interface ImageSetsNormalizationNodeOptions {
66
/** DICOM files */
77
files: string[] | File[] | BinaryFile[]
88

9+
/** Create series so that all instances in a series share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Series UID and Frame Of Reference UID tags. */
10+
seriesGroupBy?: JsonCompatible
11+
12+
/** Create image sets so that all series in a set share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Study UID tag. */
13+
imageSetGroupBy?: JsonCompatible
14+
915
}
1016

1117
export default ImageSetsNormalizationNodeOptions

packages/dicom/typescript/src/image-sets-normalization-node.ts

+12
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ async function imageSetsNormalizationNode(
5353
args.push(value as string)
5454
})
5555
}
56+
if (options.seriesGroupBy) {
57+
const inputCountString = inputs.length.toString()
58+
inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.seriesGroupBy as JsonCompatible })
59+
args.push('--series-group-by', inputCountString)
60+
61+
}
62+
if (options.imageSetGroupBy) {
63+
const inputCountString = inputs.length.toString()
64+
inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.imageSetGroupBy as JsonCompatible })
65+
args.push('--image-set-group-by', inputCountString)
66+
67+
}
5668

5769
const pipelinePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'pipelines', 'image-sets-normalization')
5870

Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
// Generated file. To retain edits, remove this comment.
22

3-
import { BinaryFile, WorkerPoolFunctionOption } from 'itk-wasm'
3+
import { BinaryFile,JsonCompatible, WorkerPoolFunctionOption } from 'itk-wasm'
44

55
interface ImageSetsNormalizationOptions extends WorkerPoolFunctionOption {
66
/** DICOM files */
77
files: string[] | File[] | BinaryFile[]
88

9+
/** Create series so that all instances in a series share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Series UID and Frame Of Reference UID tags. */
10+
seriesGroupBy?: JsonCompatible
11+
12+
/** Create image sets so that all series in a set share these tags. Option is a JSON object with a "tags" array. Example tag: "0008|103e". If not provided, defaults to Study UID tag. */
13+
imageSetGroupBy?: JsonCompatible
14+
915
}
1016

1117
export default ImageSetsNormalizationOptions

packages/dicom/typescript/src/image-sets-normalization.ts

+12
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ async function imageSetsNormalization(
6060
args.push(name)
6161
}))
6262
}
63+
if (options.seriesGroupBy) {
64+
const inputCountString = inputs.length.toString()
65+
inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.seriesGroupBy as JsonCompatible })
66+
args.push('--series-group-by', inputCountString)
67+
68+
}
69+
if (options.imageSetGroupBy) {
70+
const inputCountString = inputs.length.toString()
71+
inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.imageSetGroupBy as JsonCompatible })
72+
args.push('--image-set-group-by', inputCountString)
73+
74+
}
6375

6476
const pipelinePath = 'image-sets-normalization'
6577

0 commit comments

Comments
 (0)