From 2234a56b0e98dcc96ea95b69f0a5f1c5bba55786 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 4 Apr 2024 14:30:14 -0400 Subject: [PATCH] WIP feat(image-sets-normalization): add group by options Also, remove import of "itkGDCMImageIO.h" by extracting CharStringToUTF8Converter --- .../dicom/gdcm/CharStringToUTF8Converter.h | 467 ++++++++++++++++++ packages/dicom/gdcm/DICOMTagReader.h | 440 +---------------- packages/dicom/gdcm/Tags.h | 1 - packages/dicom/gdcm/TagsOptionParser.h | 62 +++ .../dicom/gdcm/image-sets-normalization.cxx | 41 +- .../image_sets_normalization_async.py | 12 + .../image_sets_normalization.py | 20 + .../tests/test_image_sets_normalization.py | 8 + .../itkwasm_dicom/image_sets_normalization.py | 10 +- .../image_sets_normalization_async.py | 10 +- .../image-sets-normalization-node-options.ts | 8 +- .../src/image-sets-normalization-node.ts | 12 + .../src/image-sets-normalization-options.ts | 8 +- .../src/image-sets-normalization.ts | 12 + .../test/browser/demo-app/index.html | 6 + 15 files changed, 660 insertions(+), 457 deletions(-) create mode 100644 packages/dicom/gdcm/CharStringToUTF8Converter.h create mode 100644 packages/dicom/gdcm/TagsOptionParser.h diff --git a/packages/dicom/gdcm/CharStringToUTF8Converter.h b/packages/dicom/gdcm/CharStringToUTF8Converter.h new file mode 100644 index 000000000..64ddf5f4e --- /dev/null +++ b/packages/dicom/gdcm/CharStringToUTF8Converter.h @@ -0,0 +1,467 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ +#ifndef CHAR_STRING_TO_UTF8_CONVERTER_H +#define CHAR_STRING_TO_UTF8_CONVERTER_H + +#include +#include +#include + +#include + +const std::string DEFAULT_ENCODING("ISO_IR 6"); +const std::string DEFAULT_ISO_2022_ENCODING("ISO 2022 IR 6"); +constexpr const char *ASCII = "ASCII"; + +// delimiters: CR, LF, FF, ESC, TAB (see +// https://dicom.nema.org/medical/dicom/current/output/html/part05.html#sect_6.1.3, +// table 6.1-1) +// Also includes 05/12 (BACKSLASH in IR 13 or YEN SIGN in IR 14), since that +// separates Data Element Values and it resets to initial charset. +// See: dicom part 5, sect 6.1.2.5.3 +constexpr const char *DEFAULT_DELIMS = "\x1b\x09\x0a\x0c\x0d\x5c"; +// DEFAULT_DELIMS + "^" and "=" +constexpr const char *PATIENT_NAME_DELIMS = "\x1b\x09\x0a\x0c\x0d\x5c^="; + +// If not found, then pos == len +size_t +findDelim(const char *str, size_t len, size_t pos = 0, const char *delims = DEFAULT_DELIMS) +{ + while (pos < len && strchr(delims, str[pos]) == nullptr) + { + ++pos; + } + return pos; +} + +std::string +trimWhitespace(const std::string &term) +{ + auto start = term.begin(); + auto end = term.end(); + + while (start != end && std::isspace(*start)) + { + ++start; + } + + // need to --end once before checking isspace + do + { + --end; + } while (end != start && std::isspace(*end)); + + return std::string(start, end + 1); +} + +std::string +normalizeTerm(const std::string &term) +{ + return trimWhitespace(term); +} + +const char * +definedTermToIconvCharset(const std::string &defTerm) +{ + // be strict about comparing defined terms, so no fancy parsing + // that could possibly make these operations faster. + // See: + // https://dicom.nema.org/medical/dicom/current/output/chtml/part02/sect_D.6.2.html + if (defTerm == "ISO_IR 6" || defTerm == "ISO 2022 IR 6") + { + return ASCII; + } + if (defTerm == "ISO_IR 100" || defTerm == "ISO 2022 IR 100") + { + return "ISO-8859-1"; // Latin 1 + } + if (defTerm == "ISO_IR 101" || defTerm == "ISO 2022 IR 101") + { + return "ISO-8859-2"; // Latin 2 + } + if (defTerm == "ISO_IR 109" || defTerm == "ISO 2022 IR 109") + { + return "ISO-8859-3"; // Latin 3 + } + if (defTerm == "ISO_IR 110" || defTerm == "ISO 2022 IR 110") + { + return "ISO-8859-4"; // Latin 4 + } + if (defTerm == "ISO_IR 144" || defTerm == "ISO 2022 IR 144") + { + return "ISO-8859-5"; // Cyrillic + } + if (defTerm == "ISO_IR 127" || defTerm == "ISO 2022 IR 127") + { + return "ISO-8859-6"; // Arabic + } + if (defTerm == "ISO_IR 126" || defTerm == "ISO 2022 IR 126") + { + return "ISO-8859-7"; // Greek + } + if (defTerm == "ISO_IR 138" || defTerm == "ISO 2022 IR 138") + { + return "ISO-8859-8"; // Hebrew + } + if (defTerm == "ISO_IR 148" || defTerm == "ISO 2022 IR 148") + { + return "ISO-8859-9"; // Latin 5, Turkish + } + if (defTerm == "ISO_IR 13" || defTerm == "ISO 2022 IR 13") + { + // while technically not strict, SHIFT_JIS succeeds JIS X 0201 + // See: https://en.wikipedia.org/wiki/JIS_X_0201 + return "SHIFT_JIS"; // Japanese + } + if (defTerm == "ISO_IR 166" || defTerm == "ISO 2022 IR 166") + { + return "TIS-620"; // Thai + } + if (defTerm == "ISO 2022 IR 87") + { + // see: https://en.wikipedia.org/wiki/JIS_X_0208 + return "ISO-2022-JP"; // Japanese + } + if (defTerm == "ISO 2022 IR 159") + { + // see: https://en.wikipedia.org/wiki/JIS_X_0212 + return "ISO-2022-JP-1"; // Japanese + } + if (defTerm == "ISO 2022 IR 149") + { + return "EUC-KR"; // Korean + } + if (defTerm == "ISO 2022 IR 58") + { + return "EUC-CN"; // Chinese + } + if (defTerm == "ISO_IR 192") + { + return "UTF-8"; + } + if (defTerm == "GB18030") + { + return "GB18030"; + } + if (defTerm == "GBK") + { + return "GBK"; + } + return nullptr; +} + +// seq should be the sequence after the ESC char +// return value should match in definedTermToIconvCharset +const char * +iso2022EscSelectCharset(const char *seq) +{ + if (seq[0] == '(' && seq[1] == 'B') + { + return "ISO 2022 IR 6"; + } + if (seq[0] == '-' && seq[1] == 'A') + { + return "ISO 2022 IR 100"; + } + if (seq[0] == '-' && seq[1] == 'B') + { + return "ISO 2022 IR 101"; + } + if (seq[0] == '-' && seq[1] == 'C') + { + return "ISO 2022 IR 109"; + } + if (seq[0] == '-' && seq[1] == 'D') + { + return "ISO 2022 IR 110"; + } + if (seq[0] == '-' && seq[1] == 'L') + { + return "ISO 2022 IR 144"; + } + if (seq[0] == '-' && seq[1] == 'G') + { + return "ISO 2022 IR 127"; + } + if (seq[0] == '-' && seq[1] == 'F') + { + return "ISO 2022 IR 126"; + } + if (seq[0] == '-' && seq[1] == 'H') + { + return "ISO 2022 IR 138"; + } + if (seq[0] == '-' && seq[1] == 'M') + { + return "ISO 2022 IR 148"; + } + // technically 'J' corresponds to IR 14, byt SHIFT_JIS should still work + if (seq[0] == '-' && (seq[1] == 'I' || seq[1] == 'J')) + { + return "ISO 2022 IR 13"; + } + if (seq[0] == '-' && seq[1] == 'T') + { + return "ISO 2022 IR 166"; + } + if (seq[0] == '$' && seq[1] == 'B') + { + return "ISO 2022 IR 87"; + } + if (seq[0] == '$' && seq[1] == '(' && seq[2] == 'D') + { + return "ISO 2022 IR 159"; + } + if (seq[0] == '$' && seq[1] == ')' && seq[2] == 'C') + { + return "ISO 2022 IR 149"; + } + if (seq[0] == '$' && seq[1] == ')' && seq[2] == 'A') + { + return "ISO 2022 IR 58"; + } + if ((seq[0] == ')' && seq[1] == 'I') || (seq[0] == '(' && seq[1] == 'J')) + { + return "ISO 2022 IR 13"; + } + return ""; +} + +// seq should point after the ESC char. Returned length will +// not include ESC char. +size_t +iso2022EscSeqLength(const char *seq) +{ + if (seq[0] == '$' && seq[1] >= '(' && seq[1] <= '/') + { + return 3; + } + return 2; +} + +class CharStringToUTF8Converter +{ +public: + // See: setSpecificCharacterSet(const char *) + CharStringToUTF8Converter(const std::string &spcharsets) + : CharStringToUTF8Converter(spcharsets.c_str()) + { + } + CharStringToUTF8Converter(const char *spcharsets) + : handlePatientName(false) + { + this->setSpecificCharacterSet(spcharsets); + }; + + /** + * Input must be the DICOM SpecificCharacterSet element value. + * See: + * https://dicom.nema.org/medical/dicom/current/output/html/part03.html#sect_C.12.1.1.2 + */ + void + setSpecificCharacterSet(const char *spcharsets) + { + std::string specificCharacterSet(spcharsets); + std::string token; + std::istringstream tokStream(specificCharacterSet); + + m_charsets.clear(); + + int count = 0; + while (std::getline(tokStream, token, '\\')) + { + token = normalizeTerm(token); + + // case: first element is empty. Use default ISO-IR 6 encoding. + if (token.size() == 0 && count == 0) + { + m_charsets.push_back(DEFAULT_ENCODING); + // "Hack" to handle case where ISO-646 (dicom default encoding) is + // implicitly first in the list. Since we check for charset existence when + // switching charsets as per ISO 2022, we put both regular and ISO 2022 + // names for the default encoding. + m_charsets.push_back(DEFAULT_ISO_2022_ENCODING); + } + else if (m_charsets.end() == std::find(m_charsets.begin(), m_charsets.end(), token)) + { + // case: no duplicates + const char *chname = definedTermToIconvCharset(token); + // handle charsets that do not allow code extensions + if (count > 0 && (token == "GB18030" || token == "GBK" || token == "ISO_IR 192")) + { + std::cerr << "WARN: charset " << token << " does not support code extensions; ignoring" << std::endl; + } + else if (chname != nullptr && chname != ASCII) + { + // ISO_IR 6 isn't a formally recognized defined term, so use ASCII + // above. + m_charsets.push_back(token); + } + } + else + { + std::cerr << "WARN: Found duplicate charset '" + token + "'; ignoring" << std::endl; + } + ++count; + } + + if (count == 0) + { + // use default encoding + m_charsets.push_back(DEFAULT_ENCODING); + } + + if (m_charsets.size() == 0) + { + std::cerr << "WARN: Found no suitable charsets!" << std::endl; + } + } + + std::string + convertCharStringToUTF8(const std::string &str) const + { + size_t len = str.size(); + return this->convertCharStringToUTF8(str.c_str(), len); + } + + std::string + convertCharStringToUTF8(const char *str, size_t len) const + { + // m_charsets must always have at least 1 element prior to calling + const char *initialCharset = definedTermToIconvCharset(m_charsets[0]); + if (initialCharset == nullptr) + { + return {}; + } + + iconv_t cd = iconv_open("UTF-8", initialCharset); + if (cd == (iconv_t)-1) + { + return {}; + } + + int utf8len = len * 4; + std::unique_ptr result(new char[utf8len + 1]()); // UTF8 will have max length of utf8len + + // make a copy because iconv requires a char * + char *copiedStr = (char *)malloc(len + 1); + strncpy(copiedStr, str, len); + + char *inbuf = copiedStr; + char *outbuf = result.get(); + size_t inbytesleft = len; + size_t outbytesleft = utf8len; + + // special case: only one charset, so assume string is just that charset. + if (m_charsets.size() == 1) + { + iconv(cd, &inbuf, &inbytesleft, &outbuf, &outbytesleft); + } + else + { + size_t fragmentStart = 0; + size_t fragmentEnd = 0; + + while (fragmentStart < len) + { + const char *delims = this->handlePatientName ? PATIENT_NAME_DELIMS : DEFAULT_DELIMS; + // fragmentEnd will always be end of current fragment (exclusive end) + fragmentEnd = findDelim(str, len, fragmentStart + 1, delims); + inbuf = copiedStr + fragmentStart; + inbytesleft = fragmentEnd - fragmentStart; + + iconv(cd, &inbuf, &inbytesleft, &outbuf, &outbytesleft); + + fragmentStart = fragmentEnd; + bool isEsc = str[fragmentEnd] == 0x1b; + + if (fragmentStart < len) + { + const char *nextCharset; + int seek = 0; + + if (isEsc) + { // case: ISO 2022 escape encountered + const char *escSeq = copiedStr + fragmentStart + 1; + + const char *nextTerm = iso2022EscSelectCharset(escSeq); + nextCharset = definedTermToIconvCharset(std::string(nextTerm)); + if (nextCharset == nullptr || m_charsets.end() == std::find(m_charsets.begin(), m_charsets.end(), nextTerm)) + { + std::cerr << "WARN: bailing because invalid charset: " << nextTerm << std::endl; + break; // bail out + } + + // ISO-2022-JP is a variant on ISO 2022 for japanese, and so + // it defines its own escape sequences. As such, do not skip the + // escape sequences for ISO-2022-JP, so iconv can properly interpret + // them. + if (0 != strcmp("ISO-2022-JP", nextCharset) && 0 != strcmp("ISO-2022-JP-1", nextCharset)) + { + seek = iso2022EscSeqLength(escSeq) + 1; + } + } + else + { // case: hit a CR, LF, or FF + // reset to initial charset + nextCharset = initialCharset; + } + + if (0 != iconv_close(cd)) + { + std::cerr << "WARN: bailing because iconv_close" << std::endl; + break; // bail out + } + cd = iconv_open("UTF-8", nextCharset); + if (cd == (iconv_t)-1) + { + std::cerr << "WARN: bailing because iconv_open" << std::endl; + break; // bail out + } + + fragmentStart += seek; + } + } + } + + free(copiedStr); + iconv_close(cd); + + // since result is filled with NULL bytes, string constructor will figure out + // the correct string ending. + return std::string(result.get()); + } + + bool + getHandlePatientName() + { + return this->handlePatientName; + } + + void + setHandlePatientName(bool yn) + { + this->handlePatientName = yn; + } + +private: + std::vector m_charsets; + bool handlePatientName; +}; + +#endif // CHAR_STRING_TO_UTF8_CONVERTER_H \ No newline at end of file diff --git a/packages/dicom/gdcm/DICOMTagReader.h b/packages/dicom/gdcm/DICOMTagReader.h index 19ea617ee..59aed7622 100644 --- a/packages/dicom/gdcm/DICOMTagReader.h +++ b/packages/dicom/gdcm/DICOMTagReader.h @@ -40,19 +40,7 @@ #include "itkImageIOBase.h" #include "itkMetaDataObject.h" -const std::string DEFAULT_ENCODING("ISO_IR 6"); -const std::string DEFAULT_ISO_2022_ENCODING("ISO 2022 IR 6"); -constexpr const char *ASCII = "ASCII"; - -// delimiters: CR, LF, FF, ESC, TAB (see -// https://dicom.nema.org/medical/dicom/current/output/html/part05.html#sect_6.1.3, -// table 6.1-1) -// Also includes 05/12 (BACKSLASH in IR 13 or YEN SIGN in IR 14), since that -// separates Data Element Values and it resets to initial charset. -// See: dicom part 5, sect 6.1.2.5.3 -constexpr const char *DEFAULT_DELIMS = "\x1b\x09\x0a\x0c\x0d\x5c"; -// DEFAULT_DELIMS + "^" and "=" -constexpr const char *PATIENT_NAME_DELIMS = "\x1b\x09\x0a\x0c\x0d\x5c^="; +#include "CharStringToUTF8Converter.h" std::string unpackMetaAsString(const itk::MetaDataObjectBase::Pointer &metaValue) @@ -66,432 +54,6 @@ unpackMetaAsString(const itk::MetaDataObjectBase::Pointer &metaValue) return {}; } -// If not found, then pos == len -size_t -findDelim(const char *str, size_t len, size_t pos = 0, const char *delims = DEFAULT_DELIMS) -{ - while (pos < len && strchr(delims, str[pos]) == nullptr) - { - ++pos; - } - return pos; -} - -std::string -trimWhitespace(const std::string &term) -{ - auto start = term.begin(); - auto end = term.end(); - - while (start != end && std::isspace(*start)) - { - ++start; - } - - // need to --end once before checking isspace - do - { - --end; - } while (end != start && std::isspace(*end)); - - return std::string(start, end + 1); -} - -std::string -normalizeTerm(const std::string &term) -{ - return trimWhitespace(term); -} - -const char * -definedTermToIconvCharset(const std::string &defTerm) -{ - // be strict about comparing defined terms, so no fancy parsing - // that could possibly make these operations faster. - // See: - // https://dicom.nema.org/medical/dicom/current/output/chtml/part02/sect_D.6.2.html - if (defTerm == "ISO_IR 6" || defTerm == "ISO 2022 IR 6") - { - return ASCII; - } - if (defTerm == "ISO_IR 100" || defTerm == "ISO 2022 IR 100") - { - return "ISO-8859-1"; // Latin 1 - } - if (defTerm == "ISO_IR 101" || defTerm == "ISO 2022 IR 101") - { - return "ISO-8859-2"; // Latin 2 - } - if (defTerm == "ISO_IR 109" || defTerm == "ISO 2022 IR 109") - { - return "ISO-8859-3"; // Latin 3 - } - if (defTerm == "ISO_IR 110" || defTerm == "ISO 2022 IR 110") - { - return "ISO-8859-4"; // Latin 4 - } - if (defTerm == "ISO_IR 144" || defTerm == "ISO 2022 IR 144") - { - return "ISO-8859-5"; // Cyrillic - } - if (defTerm == "ISO_IR 127" || defTerm == "ISO 2022 IR 127") - { - return "ISO-8859-6"; // Arabic - } - if (defTerm == "ISO_IR 126" || defTerm == "ISO 2022 IR 126") - { - return "ISO-8859-7"; // Greek - } - if (defTerm == "ISO_IR 138" || defTerm == "ISO 2022 IR 138") - { - return "ISO-8859-8"; // Hebrew - } - if (defTerm == "ISO_IR 148" || defTerm == "ISO 2022 IR 148") - { - return "ISO-8859-9"; // Latin 5, Turkish - } - if (defTerm == "ISO_IR 13" || defTerm == "ISO 2022 IR 13") - { - // while technically not strict, SHIFT_JIS succeeds JIS X 0201 - // See: https://en.wikipedia.org/wiki/JIS_X_0201 - return "SHIFT_JIS"; // Japanese - } - if (defTerm == "ISO_IR 166" || defTerm == "ISO 2022 IR 166") - { - return "TIS-620"; // Thai - } - if (defTerm == "ISO 2022 IR 87") - { - // see: https://en.wikipedia.org/wiki/JIS_X_0208 - return "ISO-2022-JP"; // Japanese - } - if (defTerm == "ISO 2022 IR 159") - { - // see: https://en.wikipedia.org/wiki/JIS_X_0212 - return "ISO-2022-JP-1"; // Japanese - } - if (defTerm == "ISO 2022 IR 149") - { - return "EUC-KR"; // Korean - } - if (defTerm == "ISO 2022 IR 58") - { - return "EUC-CN"; // Chinese - } - if (defTerm == "ISO_IR 192") - { - return "UTF-8"; - } - if (defTerm == "GB18030") - { - return "GB18030"; - } - if (defTerm == "GBK") - { - return "GBK"; - } - return nullptr; -} - -// seq should be the sequence after the ESC char -// return value should match in definedTermToIconvCharset -const char * -iso2022EscSelectCharset(const char *seq) -{ - if (seq[0] == '(' && seq[1] == 'B') - { - return "ISO 2022 IR 6"; - } - if (seq[0] == '-' && seq[1] == 'A') - { - return "ISO 2022 IR 100"; - } - if (seq[0] == '-' && seq[1] == 'B') - { - return "ISO 2022 IR 101"; - } - if (seq[0] == '-' && seq[1] == 'C') - { - return "ISO 2022 IR 109"; - } - if (seq[0] == '-' && seq[1] == 'D') - { - return "ISO 2022 IR 110"; - } - if (seq[0] == '-' && seq[1] == 'L') - { - return "ISO 2022 IR 144"; - } - if (seq[0] == '-' && seq[1] == 'G') - { - return "ISO 2022 IR 127"; - } - if (seq[0] == '-' && seq[1] == 'F') - { - return "ISO 2022 IR 126"; - } - if (seq[0] == '-' && seq[1] == 'H') - { - return "ISO 2022 IR 138"; - } - if (seq[0] == '-' && seq[1] == 'M') - { - return "ISO 2022 IR 148"; - } - // technically 'J' corresponds to IR 14, byt SHIFT_JIS should still work - if (seq[0] == '-' && (seq[1] == 'I' || seq[1] == 'J')) - { - return "ISO 2022 IR 13"; - } - if (seq[0] == '-' && seq[1] == 'T') - { - return "ISO 2022 IR 166"; - } - if (seq[0] == '$' && seq[1] == 'B') - { - return "ISO 2022 IR 87"; - } - if (seq[0] == '$' && seq[1] == '(' && seq[2] == 'D') - { - return "ISO 2022 IR 159"; - } - if (seq[0] == '$' && seq[1] == ')' && seq[2] == 'C') - { - return "ISO 2022 IR 149"; - } - if (seq[0] == '$' && seq[1] == ')' && seq[2] == 'A') - { - return "ISO 2022 IR 58"; - } - if ((seq[0] == ')' && seq[1] == 'I') || (seq[0] == '(' && seq[1] == 'J')) - { - return "ISO 2022 IR 13"; - } - return ""; -} - -// seq should point after the ESC char. Returned length will -// not include ESC char. -size_t -iso2022EscSeqLength(const char *seq) -{ - if (seq[0] == '$' && seq[1] >= '(' && seq[1] <= '/') - { - return 3; - } - return 2; -} - -class CharStringToUTF8Converter -{ -public: - // See: setSpecificCharacterSet(const char *) - CharStringToUTF8Converter(const std::string &spcharsets) - : CharStringToUTF8Converter(spcharsets.c_str()) - { - } - CharStringToUTF8Converter(const char *spcharsets) - : handlePatientName(false) - { - this->setSpecificCharacterSet(spcharsets); - }; - - /** - * Input must be the DICOM SpecificCharacterSet element value. - * See: - * https://dicom.nema.org/medical/dicom/current/output/html/part03.html#sect_C.12.1.1.2 - */ - void - setSpecificCharacterSet(const char *spcharsets) - { - std::string specificCharacterSet(spcharsets); - std::string token; - std::istringstream tokStream(specificCharacterSet); - - m_charsets.clear(); - - int count = 0; - while (std::getline(tokStream, token, '\\')) - { - token = normalizeTerm(token); - - // case: first element is empty. Use default ISO-IR 6 encoding. - if (token.size() == 0 && count == 0) - { - m_charsets.push_back(DEFAULT_ENCODING); - // "Hack" to handle case where ISO-646 (dicom default encoding) is - // implicitly first in the list. Since we check for charset existence when - // switching charsets as per ISO 2022, we put both regular and ISO 2022 - // names for the default encoding. - m_charsets.push_back(DEFAULT_ISO_2022_ENCODING); - } - else if (m_charsets.end() == std::find(m_charsets.begin(), m_charsets.end(), token)) - { - // case: no duplicates - const char *chname = definedTermToIconvCharset(token); - // handle charsets that do not allow code extensions - if (count > 0 && (token == "GB18030" || token == "GBK" || token == "ISO_IR 192")) - { - std::cerr << "WARN: charset " << token << " does not support code extensions; ignoring" << std::endl; - } - else if (chname != nullptr && chname != ASCII) - { - // ISO_IR 6 isn't a formally recognized defined term, so use ASCII - // above. - m_charsets.push_back(token); - } - } - else - { - std::cerr << "WARN: Found duplicate charset '" + token + "'; ignoring" << std::endl; - } - ++count; - } - - if (count == 0) - { - // use default encoding - m_charsets.push_back(DEFAULT_ENCODING); - } - - if (m_charsets.size() == 0) - { - std::cerr << "WARN: Found no suitable charsets!" << std::endl; - } - } - - std::string - convertCharStringToUTF8(const std::string &str) const - { - size_t len = str.size(); - return this->convertCharStringToUTF8(str.c_str(), len); - } - - std::string - convertCharStringToUTF8(const char *str, size_t len) const - { - // m_charsets must always have at least 1 element prior to calling - const char *initialCharset = definedTermToIconvCharset(m_charsets[0]); - if (initialCharset == nullptr) - { - return {}; - } - - iconv_t cd = iconv_open("UTF-8", initialCharset); - if (cd == (iconv_t)-1) - { - return {}; - } - - int utf8len = len * 4; - std::unique_ptr result(new char[utf8len + 1]()); // UTF8 will have max length of utf8len - - // make a copy because iconv requires a char * - char *copiedStr = (char *)malloc(len + 1); - strncpy(copiedStr, str, len); - - char *inbuf = copiedStr; - char *outbuf = result.get(); - size_t inbytesleft = len; - size_t outbytesleft = utf8len; - - // special case: only one charset, so assume string is just that charset. - if (m_charsets.size() == 1) - { - iconv(cd, &inbuf, &inbytesleft, &outbuf, &outbytesleft); - } - else - { - size_t fragmentStart = 0; - size_t fragmentEnd = 0; - - while (fragmentStart < len) - { - const char *delims = this->handlePatientName ? PATIENT_NAME_DELIMS : DEFAULT_DELIMS; - // fragmentEnd will always be end of current fragment (exclusive end) - fragmentEnd = findDelim(str, len, fragmentStart + 1, delims); - inbuf = copiedStr + fragmentStart; - inbytesleft = fragmentEnd - fragmentStart; - - iconv(cd, &inbuf, &inbytesleft, &outbuf, &outbytesleft); - - fragmentStart = fragmentEnd; - bool isEsc = str[fragmentEnd] == 0x1b; - - if (fragmentStart < len) - { - const char *nextCharset; - int seek = 0; - - if (isEsc) - { // case: ISO 2022 escape encountered - const char *escSeq = copiedStr + fragmentStart + 1; - - const char *nextTerm = iso2022EscSelectCharset(escSeq); - nextCharset = definedTermToIconvCharset(std::string(nextTerm)); - if (nextCharset == nullptr || m_charsets.end() == std::find(m_charsets.begin(), m_charsets.end(), nextTerm)) - { - std::cerr << "WARN: bailing because invalid charset: " << nextTerm << std::endl; - break; // bail out - } - - // ISO-2022-JP is a variant on ISO 2022 for japanese, and so - // it defines its own escape sequences. As such, do not skip the - // escape sequences for ISO-2022-JP, so iconv can properly interpret - // them. - if (0 != strcmp("ISO-2022-JP", nextCharset) && 0 != strcmp("ISO-2022-JP-1", nextCharset)) - { - seek = iso2022EscSeqLength(escSeq) + 1; - } - } - else - { // case: hit a CR, LF, or FF - // reset to initial charset - nextCharset = initialCharset; - } - - if (0 != iconv_close(cd)) - { - std::cerr << "WARN: bailing because iconv_close" << std::endl; - break; // bail out - } - cd = iconv_open("UTF-8", nextCharset); - if (cd == (iconv_t)-1) - { - std::cerr << "WARN: bailing because iconv_open" << std::endl; - break; // bail out - } - - fragmentStart += seek; - } - } - } - - free(copiedStr); - iconv_close(cd); - - // since result is filled with NULL bytes, string constructor will figure out - // the correct string ending. - return std::string(result.get()); - } - - bool - getHandlePatientName() - { - return this->handlePatientName; - } - - void - setHandlePatientName(bool yn) - { - this->handlePatientName = yn; - } - -private: - std::vector m_charsets; - bool handlePatientName; -}; - namespace itk { diff --git a/packages/dicom/gdcm/Tags.h b/packages/dicom/gdcm/Tags.h index c7e055357..9a9014b46 100644 --- a/packages/dicom/gdcm/Tags.h +++ b/packages/dicom/gdcm/Tags.h @@ -20,7 +20,6 @@ #include #include -#include "itkGDCMImageIO.h" using Tag = gdcm::Tag; using Tags = std::set; diff --git a/packages/dicom/gdcm/TagsOptionParser.h b/packages/dicom/gdcm/TagsOptionParser.h new file mode 100644 index 000000000..89dc852db --- /dev/null +++ b/packages/dicom/gdcm/TagsOptionParser.h @@ -0,0 +1,62 @@ +/*========================================================================= + * + * Copyright NumFOCUS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *=========================================================================*/ +#ifndef TAGS_OPTION_PARSER_H +#define TAGS_OPTION_PARSER_H + +#include +#include "rapidjson/document.h" + +#include "Tags.h" + +std::optional parseTags(itk::wasm::InputTextStream &tagsToRead, itk::wasm::Pipeline &pipeline) +{ + if (tagsToRead.GetPointer() == nullptr) + { + return std::nullopt; + } + + rapidjson::Document inputTagsDocument; + const std::string inputTagsString((std::istreambuf_iterator(tagsToRead.Get())), + std::istreambuf_iterator()); + if (inputTagsDocument.Parse(inputTagsString.c_str()).HasParseError()) + { + CLI::Error err("Runtime error", "Could not parse input tags JSON.", 1); + pipeline.exit(err); + return std::nullopt; + } + if (!inputTagsDocument.HasMember("tags")) + { + CLI::Error err("Runtime error", "Input tags does not have expected \"tags\" member", 1); + pipeline.exit(err); + return std::nullopt; + } + + const rapidjson::Value &inputTagsArray = inputTagsDocument["tags"]; + + Tags tags; + for (rapidjson::Value::ConstValueIterator itr = inputTagsArray.Begin(); itr != inputTagsArray.End(); ++itr) + { + const std::string tagString(itr->GetString()); + Tag tag; + tag.ReadFromPipeSeparatedString(tagString.c_str()); + tags.insert(tag); + } + return tags; +} + +#endif // TAGS_OPTION_PARSER_H \ No newline at end of file diff --git a/packages/dicom/gdcm/image-sets-normalization.cxx b/packages/dicom/gdcm/image-sets-normalization.cxx index ee39e0510..d32b2f5e0 100644 --- a/packages/dicom/gdcm/image-sets-normalization.cxx +++ b/packages/dicom/gdcm/image-sets-normalization.cxx @@ -43,15 +43,22 @@ #include "itkMakeUniqueForOverwrite.h" #include "itkPipeline.h" +#include "itkInputTextStream.h" #include "itkOutputTextStream.h" -#include "DICOMTagReader.h" +#include "CharStringToUTF8Converter.h" #include "Tags.h" +#include "TagsOptionParser.h" #include "SortSpatially.h" +const Tags SERIES_GROUP_BY_DEFAULT = Tags{SERIES_UID, FRAME_OF_REFERENCE_UID}; +const Tags IMAGE_SET_GROUP_BY_DEFAULT = Tags{STUDY_UID}; + + std::string getLabelFromTag(const gdcm::Tag &tag, const gdcm::DataSet &dataSet) { - if (tag.IsPrivateCreator()) { + if (tag.IsPrivateCreator()) + { return tag.PrintAsContinuousUpperCaseString(); } std::string strowner; @@ -529,7 +536,6 @@ using DicomFiles = std::unordered_set; DicomFiles loadFiles(const std::vector &fileNames) { DicomFiles dicomFiles; - itk::DICOMTagReader tagReader; for (const FileName &fileName : fileNames) { dicomFiles.insert(DicomFile(fileName)); @@ -559,20 +565,19 @@ bool compareTags(const gdcm::DataSet &tagsA, const gdcm::DataSet &tagsB, const T return true; } -bool isSameVolume(const gdcm::DataSet &tagsA, const gdcm::DataSet &tagsB) +bool isSameVolume(const gdcm::DataSet &tagsA, const gdcm::DataSet &tagsB, const Tags &criteria) { - const Tags criteria = {SERIES_UID, FRAME_OF_REFERENCE_UID}; return compareTags(tagsA, tagsB, criteria); } -Volumes groupByVolume(const DicomFiles &dicomFiles) +Volumes groupByVolume(const DicomFiles &dicomFiles, const Tags &criteria = {SERIES_UID, FRAME_OF_REFERENCE_UID}) { Volumes volumes; for (const DicomFile &dicomFile : dicomFiles) { const auto candidate = dicomFile.dataSet; - auto matchingVolume = std::find_if(volumes.begin(), volumes.end(), [&candidate](const Volume &volume) - { return isSameVolume(volume.begin()->dataSet, candidate); }); + auto matchingVolume = std::find_if(volumes.begin(), volumes.end(), [&candidate, &criteria](const Volume &volume) + { return isSameVolume(volume.begin()->dataSet, candidate, criteria); }); if (matchingVolume != volumes.end()) { @@ -587,16 +592,16 @@ Volumes groupByVolume(const DicomFiles &dicomFiles) return volumes; } -ImageSets groupByImageSet(const Volumes &volumes) +ImageSets groupByImageSet(const Volumes &volumes, const Tags &imageSetCriteria = {STUDY_UID}) { ImageSets imageSets; for (const Volume &volume : volumes) { const gdcm::DataSet volumeDataSet = volume.begin()->dataSet; - auto matchingImageSet = std::find_if(imageSets.begin(), imageSets.end(), [&volumeDataSet](const Volumes &volumes) + auto matchingImageSet = std::find_if(imageSets.begin(), imageSets.end(), [&volumeDataSet, &imageSetCriteria](const Volumes &volumes) { const gdcm::DataSet imageSetDataSet = volumes.begin()->begin()->dataSet; - return compareTags(volumeDataSet, imageSetDataSet, {STUDY_UID}); }); + return compareTags(volumeDataSet, imageSetDataSet, imageSetCriteria); }); if (matchingImageSet != imageSets.end()) { matchingImageSet->push_back(volume); @@ -730,15 +735,25 @@ int main(int argc, char *argv[]) std::vector files; pipeline.add_option("--files", files, "DICOM files")->required()->check(CLI::ExistingFile)->type_size(1, -1)->type_name("INPUT_BINARY_FILE"); + itk::wasm::InputTextStream seriesGroupByOption; + 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"); + itk::wasm::InputTextStream imageSetGroupByOption; + 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"); + itk::wasm::OutputTextStream imageSetsOutput; pipeline.add_option("image-sets", imageSetsOutput, "Image sets JSON")->required()->type_name("OUTPUT_JSON"); ITK_WASM_PARSE(pipeline); + const std::optional seriesGroupByParse = parseTags(seriesGroupByOption, pipeline); + const Tags seriesGroupBy = seriesGroupByParse.value_or(SERIES_GROUP_BY_DEFAULT); + const std::optional imageSetGroupByParse = parseTags(imageSetGroupByOption, pipeline); + const Tags imageSetGroupBy = imageSetGroupByParse.value_or(IMAGE_SET_GROUP_BY_DEFAULT); + const DicomFiles dicomFiles = loadFiles(files); - Volumes volumes = groupByVolume(dicomFiles); + Volumes volumes = groupByVolume(dicomFiles, seriesGroupBy); volumes = sortSpatially(volumes); - const ImageSets imageSets = groupByImageSet(volumes); + const ImageSets imageSets = groupByImageSet(volumes, imageSetGroupBy); rapidjson::Document imageSetsJson = toJson(imageSets); rapidjson::StringBuffer stringBuffer; diff --git a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/image_sets_normalization_async.py b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/image_sets_normalization_async.py index 7157a1780..feb74cef6 100644 --- a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/image_sets_normalization_async.py +++ b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/image_sets_normalization_async.py @@ -18,12 +18,20 @@ async def image_sets_normalization_async( files: List[os.PathLike] = [], + series_group_by: Optional[Any] = None, + image_set_group_by: Optional[Any] = None, ) -> Any: """Group DICOM files into image sets :param files: DICOM files :type files: os.PathLike + :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. + :type series_group_by: Any + + :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. + :type image_set_group_by: Any + :return: Image sets JSON :rtype: Any """ @@ -33,6 +41,10 @@ async def image_sets_normalization_async( kwargs = {} if files is not None: kwargs["files"] = to_js(BinaryFile(files)) + if series_group_by is not None: + kwargs["seriesGroupBy"] = to_js(series_group_by) + if image_set_group_by is not None: + kwargs["imageSetGroupBy"] = to_js(image_set_group_by) outputs = await js_module.imageSetsNormalization(webWorker=web_worker, noCopy=True, **kwargs) diff --git a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/image_sets_normalization.py b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/image_sets_normalization.py index 8b60ba501..74d9f8d70 100644 --- a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/image_sets_normalization.py +++ b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/image_sets_normalization.py @@ -18,12 +18,20 @@ def image_sets_normalization( files: List[os.PathLike] = [], + series_group_by: Optional[Any] = None, + image_set_group_by: Optional[Any] = None, ) -> Any: """Group DICOM files into image sets :param files: DICOM files :type files: os.PathLike + :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. + :type series_group_by: Any + + :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. + :type image_set_group_by: Any + :return: Image sets JSON :rtype: Any """ @@ -55,6 +63,18 @@ def image_sets_normalization( pipeline_inputs.append(PipelineInput(InterfaceTypes.BinaryFile, BinaryFile(value))) args.append(input_file) + if series_group_by is not None: + pipeline_inputs.append(PipelineInput(InterfaceTypes.JsonCompatible, series_group_by)) + args.append('--series-group-by') + args.append(str(input_count)) + input_count += 1 + + if image_set_group_by is not None: + pipeline_inputs.append(PipelineInput(InterfaceTypes.JsonCompatible, image_set_group_by)) + args.append('--image-set-group-by') + args.append(str(input_count)) + input_count += 1 + outputs = _pipeline.run(args, pipeline_outputs, pipeline_inputs) diff --git a/packages/dicom/python/itkwasm-dicom-wasi/tests/test_image_sets_normalization.py b/packages/dicom/python/itkwasm-dicom-wasi/tests/test_image_sets_normalization.py index b6d4c617f..a34d956c5 100644 --- a/packages/dicom/python/itkwasm-dicom-wasi/tests/test_image_sets_normalization.py +++ b/packages/dicom/python/itkwasm-dicom-wasi/tests/test_image_sets_normalization.py @@ -52,6 +52,7 @@ def test_ct(): sorted_files = pick_files(image_sets[0]) assert_equal(sorted_files, ct_series) + def test_mr(): assert mr_series[0].exists() out_of_order = [ @@ -67,6 +68,13 @@ def test_mr(): assert_equal(sorted_files, mr_series) +def test_series_group_by_option(): + assert mr_series[0].exists() + group_by_tags = {"tags": ["0008|0018"]} # SOP Instance UID + image_sets = image_sets_normalization(mr_series, series_group_by=group_by_tags) + assert len(image_sets) == len(mr_series) + + def test_two_series(): files = [ orientation_series[1], diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization.py index 4a63d0167..0a09670b3 100644 --- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization.py +++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization.py @@ -10,15 +10,23 @@ def image_sets_normalization( files: List[os.PathLike] = [], + series_group_by: Optional[Any] = None, + image_set_group_by: Optional[Any] = None, ) -> Any: """Group DICOM files into image sets :param files: DICOM files :type files: os.PathLike + :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. + :type series_group_by: Any + + :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. + :type image_set_group_by: Any + :return: Image sets JSON :rtype: Any """ func = environment_dispatch("itkwasm_dicom", "image_sets_normalization") - output = func(files=files) + output = func(files=files, series_group_by=series_group_by, image_set_group_by=image_set_group_by) return output diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization_async.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization_async.py index e6961968f..acf1546b1 100644 --- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization_async.py +++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/image_sets_normalization_async.py @@ -10,15 +10,23 @@ async def image_sets_normalization_async( files: List[os.PathLike] = [], + series_group_by: Optional[Any] = None, + image_set_group_by: Optional[Any] = None, ) -> Any: """Group DICOM files into image sets :param files: DICOM files :type files: os.PathLike + :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. + :type series_group_by: Any + + :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. + :type image_set_group_by: Any + :return: Image sets JSON :rtype: Any """ func = environment_dispatch("itkwasm_dicom", "image_sets_normalization_async") - output = await func(files=files) + output = await func(files=files, series_group_by=series_group_by, image_set_group_by=image_set_group_by) return output diff --git a/packages/dicom/typescript/src/image-sets-normalization-node-options.ts b/packages/dicom/typescript/src/image-sets-normalization-node-options.ts index eaaf5bbaf..9e87b517d 100644 --- a/packages/dicom/typescript/src/image-sets-normalization-node-options.ts +++ b/packages/dicom/typescript/src/image-sets-normalization-node-options.ts @@ -1,11 +1,17 @@ // Generated file. To retain edits, remove this comment. -import { BinaryFile } from 'itk-wasm' +import { BinaryFile,JsonCompatible } from 'itk-wasm' interface ImageSetsNormalizationNodeOptions { /** DICOM files */ files: string[] | File[] | BinaryFile[] + /** 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. */ + seriesGroupBy?: JsonCompatible + + /** 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. */ + imageSetGroupBy?: JsonCompatible + } export default ImageSetsNormalizationNodeOptions diff --git a/packages/dicom/typescript/src/image-sets-normalization-node.ts b/packages/dicom/typescript/src/image-sets-normalization-node.ts index 8df6f2afb..079a318a2 100644 --- a/packages/dicom/typescript/src/image-sets-normalization-node.ts +++ b/packages/dicom/typescript/src/image-sets-normalization-node.ts @@ -53,6 +53,18 @@ async function imageSetsNormalizationNode( args.push(value as string) }) } + if (options.seriesGroupBy) { + const inputCountString = inputs.length.toString() + inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.seriesGroupBy as JsonCompatible }) + args.push('--series-group-by', inputCountString) + + } + if (options.imageSetGroupBy) { + const inputCountString = inputs.length.toString() + inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.imageSetGroupBy as JsonCompatible }) + args.push('--image-set-group-by', inputCountString) + + } const pipelinePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'pipelines', 'image-sets-normalization') diff --git a/packages/dicom/typescript/src/image-sets-normalization-options.ts b/packages/dicom/typescript/src/image-sets-normalization-options.ts index 574977e76..11a6422d0 100644 --- a/packages/dicom/typescript/src/image-sets-normalization-options.ts +++ b/packages/dicom/typescript/src/image-sets-normalization-options.ts @@ -1,11 +1,17 @@ // Generated file. To retain edits, remove this comment. -import { BinaryFile, WorkerPoolFunctionOption } from 'itk-wasm' +import { BinaryFile,JsonCompatible, WorkerPoolFunctionOption } from 'itk-wasm' interface ImageSetsNormalizationOptions extends WorkerPoolFunctionOption { /** DICOM files */ files: string[] | File[] | BinaryFile[] + /** 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. */ + seriesGroupBy?: JsonCompatible + + /** 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. */ + imageSetGroupBy?: JsonCompatible + } export default ImageSetsNormalizationOptions diff --git a/packages/dicom/typescript/src/image-sets-normalization.ts b/packages/dicom/typescript/src/image-sets-normalization.ts index c6e9c811e..2f9d6f7c2 100644 --- a/packages/dicom/typescript/src/image-sets-normalization.ts +++ b/packages/dicom/typescript/src/image-sets-normalization.ts @@ -60,6 +60,18 @@ async function imageSetsNormalization( args.push(name) })) } + if (options.seriesGroupBy) { + const inputCountString = inputs.length.toString() + inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.seriesGroupBy as JsonCompatible }) + args.push('--series-group-by', inputCountString) + + } + if (options.imageSetGroupBy) { + const inputCountString = inputs.length.toString() + inputs.push({ type: InterfaceTypes.JsonCompatible, data: options.imageSetGroupBy as JsonCompatible }) + args.push('--image-set-group-by', inputCountString) + + } const pipelinePath = 'image-sets-normalization' diff --git a/packages/dicom/typescript/test/browser/demo-app/index.html b/packages/dicom/typescript/test/browser/demo-app/index.html index ef196131f..18facbc6f 100644 --- a/packages/dicom/typescript/test/browser/demo-app/index.html +++ b/packages/dicom/typescript/test/browser/demo-app/index.html @@ -328,6 +328,12 @@

👨‍💻 Live API Demo ✨

+

+ + +

+ +


Load sample inputs