diff --git a/components/bio-formats-tools/src/loci/formats/tools/ImageConverter.java b/components/bio-formats-tools/src/loci/formats/tools/ImageConverter.java index cea3ee1c9bc..8f1c3816929 100644 --- a/components/bio-formats-tools/src/loci/formats/tools/ImageConverter.java +++ b/components/bio-formats-tools/src/loci/formats/tools/ImageConverter.java @@ -78,6 +78,7 @@ import loci.formats.meta.IPyramidStore; import loci.formats.out.DicomWriter; import loci.formats.ome.OMEPyramidStore; +import loci.formats.out.IExtraMetadataWriter; import loci.formats.out.TiffWriter; import loci.formats.services.OMEXMLService; import loci.formats.services.OMEXMLServiceImpl; @@ -132,6 +133,8 @@ public final class ImageConverter { private boolean precompressed = false; private boolean tryPrecompressed = false; + private String extraMetadata = null; + private IFormatReader reader; private MinMaxCalculator minMax; private DimensionSwapper dimSwapper; @@ -267,6 +270,9 @@ else if (args[i].equals("-fill")) { // allow specifying 0-255 fillColor = (byte) Integer.parseInt(args[++i]); } + else if (args[i].equals("-extra-metadata")) { + extraMetadata = args[++i]; + } else if (!args[i].equals(CommandLineTools.NO_UPGRADE_CHECK)) { LOGGER.error("Found unknown command flag: {}; exiting.", args[i]); return false; @@ -671,6 +677,15 @@ else if (w instanceof DicomWriter) { ((DicomWriter) w).setBigTiff(bigtiff); } } + if (writer instanceof IExtraMetadataWriter) { + ((IExtraMetadataWriter) writer).setExtraMetadata(extraMetadata); + } + else if (writer instanceof ImageWriter) { + IFormatWriter w = ((ImageWriter) writer).getWriter(out); + if (w instanceof IExtraMetadataWriter) { + ((IExtraMetadataWriter) w).setExtraMetadata(extraMetadata); + } + } String format = writer.getFormat(); LOGGER.info("[{}] -> {} [{}]", diff --git a/components/formats-bsd/pom.xml b/components/formats-bsd/pom.xml index 28842a8ae24..aee7ec9c5da 100644 --- a/components/formats-bsd/pom.xml +++ b/components/formats-bsd/pom.xml @@ -110,6 +110,13 @@ ${jxrlib.version} + + org.json + json + 20230227 + + + xerces diff --git a/components/formats-bsd/src/loci/formats/dicom/DicomAttribute.java b/components/formats-bsd/src/loci/formats/dicom/DicomAttribute.java index acc3cf26009..e270a604597 100644 --- a/components/formats-bsd/src/loci/formats/dicom/DicomAttribute.java +++ b/components/formats-bsd/src/loci/formats/dicom/DicomAttribute.java @@ -787,6 +787,8 @@ public enum DicomAttribute { private static final Map dict = new HashMap(); + private static final Map nameLookup = + new HashMap(); static { try (InputStream stream = DicomAttribute.class.getResourceAsStream("dicom-dictionary.txt")) { @@ -802,7 +804,9 @@ public enum DicomAttribute { if (tokens.length > 2) { tokens[2] = tokens[2].trim(); } - dict.put((int) Long.parseLong(tokens[0], 16), tokens); + int key = (int) Long.parseLong(tokens[0], 16); + dict.put(key, tokens); + nameLookup.put(tokens[1].replaceAll("\\s", "").toLowerCase(), key); } } catch (Exception e) { @@ -872,6 +876,10 @@ public static String getDescription(int newTag) { return null; } + public static Integer getTag(String description) { + return nameLookup.get(description.toLowerCase()); + } + /** * Lookup the attribute for the given tag. * May return null if the tag is not defined in our dictionary. diff --git a/components/formats-bsd/src/loci/formats/dicom/DicomJSONProvider.java b/components/formats-bsd/src/loci/formats/dicom/DicomJSONProvider.java new file mode 100644 index 00000000000..1dd6d4e962a --- /dev/null +++ b/components/formats-bsd/src/loci/formats/dicom/DicomJSONProvider.java @@ -0,0 +1,211 @@ +/* + * #%L + * BSD implementations of Bio-Formats readers and writers + * %% + * Copyright (C) 2005 - 2023 Open Microscopy Environment: + * - Board of Regents of the University of Wisconsin-Madison + * - Glencoe Software, Inc. + * - University of Dundee + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package loci.formats.dicom; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import loci.common.Constants; +import loci.common.DataTools; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provide DICOM tags from a file containing JSON. + * Formal JSON schema yet to be determined, but the idea is to accept a hierarchy + * of tags of the form: + * + * { + * "BodyPartExamined": { + * "Value": "BRAIN", + * "VR": "CS", + * "Tag": "(0018,0015)" + * } + * "ContributingEquipmentSequence": { + * "VR": "SQ", + * "Tag": "(0018,a001)", + * "Sequence": { + * "Manufacturer": { + * "Value": "PixelMed", + * "VR": "LO", + * "Tag": "(0008,0070)" + * }, + * "ContributionDateTime": { + * "Value": "20210710234601.105+0000", + * "VR": "DT", + * "Tag": "(0018,a002)" + * } + * } + * } + * } + * + * This is similar to JSON examples in https://github.com/QIICR/dcmqi/tree/master/doc/examples, + * but allows for the VR (type) and tag to be explicitly stated, in addition to + * the more human-readable description. + */ +public class DicomJSONProvider implements ITagProvider { + + private static final Logger LOGGER = + LoggerFactory.getLogger(DicomJSONProvider.class); + + private List tags = new ArrayList(); + + @Override + public void readTagSource(String location) throws IOException { + String rawJSON = DataTools.readFile(location); + try { + JSONObject root = new JSONObject(rawJSON); + + for (String tagKey : root.keySet()) { + JSONObject tag = root.getJSONObject(tagKey); + String value = tag.has("Value") ? tag.getString("Value") : null; + + Integer intTagCode = lookupTag(tagKey, tag); + DicomVR vrEnum = lookupVR(intTagCode, tag); + + DicomTag dicomTag = new DicomTag(intTagCode, vrEnum); + dicomTag.value = value; + LOGGER.debug("Adding tag: {}, VR: {}, value: {}", + DicomAttribute.formatTag(intTagCode), vrEnum, value); + dicomTag.validateValue(); + + if (vrEnum == DicomVR.SQ && tag.has("Sequence")) { + readSequence(tag, dicomTag); + } + + ResolutionStrategy rs = vrEnum == DicomVR.SQ ? ResolutionStrategy.APPEND : ResolutionStrategy.REPLACE; + if (tag.has("ResolutionStrategy")) { + String strategy = tag.getString("ResolutionStrategy"); + rs = Enum.valueOf(ResolutionStrategy.class, strategy); + } + dicomTag.strategy = rs; + + tags.add(dicomTag); + } + } + catch (JSONException e) { + throw new IOException("Could not parse JSON", e); + } + } + + @Override + public List getTags() { + return tags; + } + + private void readSequence(JSONObject rootTag, DicomTag parent) { + JSONObject sequence = rootTag.getJSONObject("Sequence"); + for (String key : sequence.keySet()) { + JSONObject tag = sequence.getJSONObject(key); + + Integer intTagCode = lookupTag(key, tag); + DicomVR vrEnum = lookupVR(intTagCode, tag); + + DicomTag dicomTag = new DicomTag(intTagCode, vrEnum); + + if (tag.has("Value")) { + dicomTag.value = tag.get("Value"); + } + + LOGGER.debug("Adding tag: {}, VR: {}, value: {}", intTagCode, vrEnum, dicomTag.value); + dicomTag.validateValue(); + + dicomTag.parent = parent; + parent.children.add(dicomTag); + + if (vrEnum == DicomVR.SQ && tag.get("Sequence") != null) { + readSequence(tag, dicomTag); + } + } + parent.children.sort(null); + } + + /** + * Get the tag code corresponding to the given JSON object. + * If "Tag" is not a defined attribute, lookup the default + * for the object name in the dictionary. + */ + private Integer lookupTag(String tagKey, JSONObject tag) { + Integer intTagCode = null; + + if (tag.has("Tag")) { + String[] tagCode = tag.getString("Tag").replaceAll("[()]", "").split(","); + + int tagUpper = Integer.parseInt(tagCode[0], 16); + int tagLower = Integer.parseInt(tagCode[1], 16); + + intTagCode = tagUpper << 16 | tagLower; + } + else { + intTagCode = DicomAttribute.getTag(tagKey); + + if (intTagCode == null) { + throw new IllegalArgumentException( + "Tag not defined and could not be determined from description '" + + tagKey + "'"); + } + } + + return intTagCode; + } + + /** + * Get the VR associated with the given tag code and JSON object. + * If "VR" is not a defined attribute, lookup the default for the + * given tag code in the dictionary. + */ + private DicomVR lookupVR(Integer intTagCode, JSONObject tag) { + DicomVR vrEnum = DicomAttribute.getDefaultVR(intTagCode); + if (tag.has("VR")) { + DicomVR userEnum = DicomVR.valueOf(DicomVR.class, tag.getString("VR")); + if (!vrEnum.equals(userEnum)) { + LOGGER.warn("User-defined VR ({}) for {} does not match expected VR ({})", + userEnum, DicomAttribute.formatTag(intTagCode), vrEnum); + if (userEnum != null) { + vrEnum = userEnum; + } + } + } + + return vrEnum; + } + + + +} diff --git a/components/formats-bsd/src/loci/formats/dicom/DicomTag.java b/components/formats-bsd/src/loci/formats/dicom/DicomTag.java index caec5c19e70..8e5a868c0ad 100644 --- a/components/formats-bsd/src/loci/formats/dicom/DicomTag.java +++ b/components/formats-bsd/src/loci/formats/dicom/DicomTag.java @@ -47,7 +47,7 @@ * Represents a complete DICOM tag, including the dictionary attribute, * actual VR, value, and any "child" tags (in the case of a sequence). */ -public class DicomTag { +public class DicomTag implements Comparable { public DicomTag parent = null; public List children = new ArrayList(); @@ -65,6 +65,10 @@ public class DicomTag { private boolean bigEndianTransferSyntax = false; private boolean oddLocations = false; + // optional indicator of how to handle this tag when merging + // into an existing tag list + public ResolutionStrategy strategy = null; + public DicomTag(DicomAttribute attribute) { this(attribute, null); } @@ -80,6 +84,17 @@ public DicomTag(DicomAttribute attribute, DicomVR vr) { this.tag = attribute.getTag(); } + public DicomTag(int tag, DicomVR vr) { + this.tag = tag; + this.attribute = DicomAttribute.get(tag); + if (vr != null) { + this.vr = vr; + } + else if (attribute != null) { + this.vr = attribute.getDefaultVR(); + } + } + /** * Read a complete tag and value from the given input stream. */ @@ -536,9 +551,174 @@ public DicomTag lookupChild(DicomAttribute attr) { return null; } + /** + * Check this tag against a list of existing tags. + * If this tag is a duplicate of an existing tag or otherwise deemed invalid, + * return false. Otherwise return true. + */ + public boolean validate(List tags) { + for (DicomTag t : tags) { + if (this.tag == t.tag) { + return strategy != ResolutionStrategy.IGNORE; + } + } + return true; + } + + /** + * Compare the value type to the VR. + * If the two types do not match, attempt to conver the value + * into the VR's type. + * Usually this means parsing numerical values from a String. + */ + public void validateValue() { + if (value == null) { + return; + } + switch (vr) { + case AE: + case AS: + case CS: + case DA: + case DS: + case DT: + case IS: + case LO: + case LT: + case PN: + case SH: + case ST: + case TM: + case UC: + case UI: + case UR: + case UT: + value = value.toString(); + break; + case AT: + case SS: + case US: + if (value instanceof Short) { + value = new short[] {(short) value}; + } + else if (value instanceof String) { + String[] values = ((String) value).split(","); + short[] v = new short[values.length]; + for (int i=0; i getTags(); + +} diff --git a/components/formats-bsd/src/loci/formats/dicom/ResolutionStrategy.java b/components/formats-bsd/src/loci/formats/dicom/ResolutionStrategy.java new file mode 100644 index 00000000000..e4b8939b059 --- /dev/null +++ b/components/formats-bsd/src/loci/formats/dicom/ResolutionStrategy.java @@ -0,0 +1,41 @@ +/* + * #%L + * BSD implementations of Bio-Formats readers and writers + * %% + * Copyright (C) 2005 - 2023 Open Microscopy Environment: + * - Board of Regents of the University of Wisconsin-Madison + * - Glencoe Software, Inc. + * - University of Dundee + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package loci.formats.dicom; + +/** + */ +public enum ResolutionStrategy { + IGNORE, + REPLACE, + APPEND; +} diff --git a/components/formats-bsd/src/loci/formats/dicom/dicom-dictionary.txt b/components/formats-bsd/src/loci/formats/dicom/dicom-dictionary.txt index ed5ab38f832..3d29d03f7ba 100644 --- a/components/formats-bsd/src/loci/formats/dicom/dicom-dictionary.txt +++ b/components/formats-bsd/src/loci/formats/dicom/dicom-dictionary.txt @@ -123,7 +123,7 @@ 00080124, "Mapping Resource Identification Sequence", SQ 00080201, "Timezone Offset from UTC", SH 00080220, "Responsible Group Code Sequence", SQ -00080220, "Equipment Modality", CS +00080221, "Equipment Modality", CS 00080222, "Manufacturer's Related Model Group", LO 00080300, "Private Data Element Characteristics Sequence", SQ 00080301, "Private Group Reference", US @@ -285,7 +285,7 @@ 00100221, "Genetic Modifications Sequence", SQ 00100222, "Genetic Modifications Description", UC 00100223, "Genetic Modifications Nomenclature", LO -00100229, "Genetic Modifications Sequence", SQ +00100229, "Genetic Modifications Code Sequence", SQ 00101000, "Other Patient IDs", LO 00101001, "Other Patient Names", PN 00101002, "Other Patient IDs Sequence", SQ @@ -385,7 +385,7 @@ 00142002, "Evaluator Sequence", SQ 00142004, "Evaluator Number", IS 00142006, "Evaluator Name", PN -00142006, "Evaluator Attempt", IS +00142008, "Evaluation Attempt", IS 00142012, "Indication Sequence", SQ 00142014, "Indication Number", IS 00142016, "Indication Label", SH diff --git a/components/formats-bsd/src/loci/formats/out/DicomWriter.java b/components/formats-bsd/src/loci/formats/out/DicomWriter.java index 274a9953cba..b3f25f64dd4 100644 --- a/components/formats-bsd/src/loci/formats/out/DicomWriter.java +++ b/components/formats-bsd/src/loci/formats/out/DicomWriter.java @@ -43,6 +43,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.List; import loci.common.Constants; import loci.common.DataTools; @@ -58,6 +59,9 @@ import loci.formats.codec.JPEG2000Codec; import loci.formats.codec.JPEG2000CodecOptions; import loci.formats.codec.JPEGCodec; +import loci.formats.dicom.DicomAttribute; +import loci.formats.dicom.DicomJSONProvider; +import loci.formats.dicom.ITagProvider; import loci.formats.dicom.DicomTag; import loci.formats.in.DynamicMetadataOptions; import loci.formats.in.MetadataOptions; @@ -82,7 +86,7 @@ * This is designed for whole slide images, and may not produce * schema-compliant files for other modalities. */ -public class DicomWriter extends FormatWriter { +public class DicomWriter extends FormatWriter implements IExtraMetadataWriter { // -- Constants -- @@ -116,6 +120,7 @@ public class DicomWriter extends FormatWriter { private String instanceUIDValue; private String implementationUID; + private ArrayList tagProviders = new ArrayList(); private boolean bigTiff = false; private TiffSaver tiffSaver; @@ -132,6 +137,34 @@ public DicomWriter() { }; } + // -- IExtraMetadataWriter API methods -- + + @Override + public void setExtraMetadata(String tagSource) { + FormatTools.assertId(currentId, false, 1); + + // get the provider (parser) from the source name + // uses the file extension, this might need improvement + + if (tagSource != null) { + ITagProvider provider = null; + if (checkSuffix(tagSource, "json")) { + provider = new DicomJSONProvider(); + } + else { + throw new IllegalArgumentException("Unknown tag format: " + tagSource); + } + + try { + provider.readTagSource(tagSource); + tagProviders.add(provider); + } + catch (IOException e) { + LOGGER.error("Could not parse extra metadata: " + tagSource, e); + } + } + } + /** * Sets whether or not BigTIFF files should be written. * This flag is not reset when close() is called. @@ -1170,13 +1203,59 @@ public void setId(String id) throws FormatException, IOException { tags.add(labelText); } + // now add all supplementary tags from tag providers + for (ITagProvider provider : tagProviders) { + for (DicomTag t : provider.getTags()) { + boolean validTag = t.validate(tags); + if (validTag) { + padTagValues(t); + + LOGGER.trace("handling supplemental tag ({}) with strategy {}", t, t.strategy); + switch (t.strategy) { + case APPEND: + if (t.vr == SQ) { + DicomTag existingSequence = lookupTag(tags, t); + if (existingSequence == null) { + tags.add(t); + } + else { + existingSequence.children.add(makeItem()); + for (DicomTag child : t.children) { + existingSequence.children.add(child); + } + existingSequence.children.add(makeItemDelimitation()); + } + } + else { + tags.add(t); + } + break; + case IGNORE: + // ignore current tag if a matching tag already exists + DicomTag existing = lookupTag(tags, t); + if (existing == null) { + tags.add(t); + } + break; + case REPLACE: + // replace existing tag with current tag + DicomTag replace = lookupTag(tags, t); + if (replace != null) { + tags.remove(replace); + } + tags.add(t); + break; + } + } + else { + LOGGER.warn("Ignoring tag {} from provider {}", t, provider); + } + } + } + // sort tags into ascending order, then write - tags.sort(new Comparator() { - public int compare(DicomTag a, DicomTag b) { - return a.attribute.getTag() - b.attribute.getTag(); - } - }); + tags.sort(null); for (DicomTag tag : tags) { writeTag(tag); @@ -1273,6 +1352,10 @@ public void close() throws IOException { long fp = out.getFilePointer(); writeIFDs(resolutionIndex); long length = out.getFilePointer() - fp; + if (length % 2 == 1) { + out.writeByte(0); + length++; + } out.seek(fp - 4); out.writeInt((int) length); } @@ -1292,6 +1375,8 @@ public void close() throws IOException { tiffSaver = null; validPixelCount = null; + tagProviders.clear(); + // intentionally don't reset tile dimensions } @@ -1359,7 +1444,7 @@ private int getStoredLength(DicomTag tag) { } private void writeTag(DicomTag tag) throws IOException { - int tagCode = tag.attribute.getTag(); + int tagCode = tag.attribute == null ? tag.tag : tag.attribute.getTag(); out.writeShort((short) ((tagCode & 0xffff0000) >> 16)); out.writeShort((short) (tagCode & 0xffff)); @@ -1870,6 +1955,30 @@ private DicomTag makeItemDelimitation() { return item; } + private DicomTag lookupTag(List tags, DicomTag compare) { + for (DicomTag t : tags) { + if (t.tag == compare.tag) { + return t; + } + } + return null; + } + + private void padTagValues(DicomTag t) { + if (t.value instanceof String) { + if (t.vr == UI) { + t.value = padUID((String) t.value); + } + else { + t.value = padString((String) t.value); + } + } + + for (DicomTag child : t.children) { + padTagValues(child); + } + } + private short[] makeShortArray(int v) { short[] s = new short[2]; s[0] = (short) ((v >> 16) & 0xffff); diff --git a/components/formats-bsd/src/loci/formats/out/IExtraMetadataWriter.java b/components/formats-bsd/src/loci/formats/out/IExtraMetadataWriter.java new file mode 100644 index 00000000000..7cc1da2170d --- /dev/null +++ b/components/formats-bsd/src/loci/formats/out/IExtraMetadataWriter.java @@ -0,0 +1,53 @@ +/* + * #%L + * BSD implementations of Bio-Formats readers and writers + * %% + * Copyright (C) 2023 Open Microscopy Environment: + * - Board of Regents of the University of Wisconsin-Madison + * - Glencoe Software, Inc. + * - University of Dundee + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package loci.formats.out; + +import java.io.IOException; + +public interface IExtraMetadataWriter { + + /** + * Provide additional metadata that should be written + * as part of this dataset. Primarily intended for DicomWriter. + * + * Calling this method is optional. Multiple calls are allowed, + * e.g. if tags come from multiple external sources. + * If multiple calls to this method are made then the order matters, + * with earlier calls implying higher priority when resolving duplicate + * or conflicting tags. + * + * All calls to this method must occur before setId is called. + */ + void setExtraMetadata(String metadataSource); + +} diff --git a/components/formats-bsd/test/loci/formats/utests/dicom/ProvidedMetadataTest.java b/components/formats-bsd/test/loci/formats/utests/dicom/ProvidedMetadataTest.java new file mode 100644 index 00000000000..e0e0cc63805 --- /dev/null +++ b/components/formats-bsd/test/loci/formats/utests/dicom/ProvidedMetadataTest.java @@ -0,0 +1,453 @@ +/* + * #%L + * BSD implementations of Bio-Formats readers and writers + * %% + * Copyright (C) 2023 Open Microscopy Environment: + * - Board of Regents of the University of Wisconsin-Madison + * - Glencoe Software, Inc. + * - University of Dundee + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package loci.formats.utests.dicom; + +import static org.testng.AssertJUnit.assertEquals; +import static org.testng.AssertJUnit.assertNotNull; +import static org.testng.AssertJUnit.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import loci.common.Constants; +import loci.formats.FormatException; +import loci.formats.MetadataTools; +import loci.formats.dicom.DicomAttribute; +import loci.formats.dicom.DicomTag; +import loci.formats.dicom.DicomVR; +import loci.formats.in.DicomReader; +import loci.formats.in.FakeReader; +import loci.formats.meta.IMetadata; +import loci.formats.out.DicomWriter; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** + */ +public class ProvidedMetadataTest { + + public Path doConversion(String jsonMetadata) throws FormatException, IOException { + Path jsonFile = Files.createTempFile("dicom", ".json"); + Path dicomFile = Files.createTempFile("metadata-test", ".dcm"); + FakeReader reader = new FakeReader(); + DicomWriter writer = new DicomWriter(); + try { + Files.write(jsonFile, jsonMetadata.getBytes(Constants.ENCODING)); + + IMetadata meta = MetadataTools.createOMEXMLMetadata(); + reader.setMetadataStore(meta); + reader.setId("test.fake"); + + writer.setExtraMetadata(jsonFile.toString()); + writer.setMetadataRetrieve(meta); + writer.setId(dicomFile.toString()); + writer.saveBytes(0, reader.openBytes(0)); + } + catch (Throwable e) { + Files.delete(dicomFile); + throw e; + } + finally { + reader.close(); + writer.close(); + Files.delete(jsonFile); + } + + return dicomFile; + } + + public List getTags(Path dicomFile) throws FormatException, IOException { + DicomReader reader = new DicomReader(); + reader.setGroupFiles(false); + reader.setId(dicomFile.toString()); + List tags = reader.getTags(); + reader.close(); + return tags; + } + + public DicomTag lookup(List tags, DicomAttribute attr) { + for (DicomTag t : tags) { + if (attr.equals(t.attribute)) { + return t; + } + } + return null; + } + + @Test + public void testSingleTagWithDefaults() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"BodyPartExamined\": {" + + "\"Value\": \"BRAIN\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + DicomTag found = lookup(tags, DicomAttribute.BODY_PART_EXAMINED); + assertNotNull(found); + + // trailing space is not a typo + // the writer pads string values to an even number of characters + assertEquals(found.value, "BRAIN "); + assertEquals(found.vr, DicomVR.CS); + } + finally { + Files.delete(dicomFile); + } + } + + @Test(expectedExceptions={ IllegalArgumentException.class }) + public void testSingleInvalidTagName() throws FormatException, IOException { + String json = "{" + + "\"not a valid tag\": {" + + "\"Value\": \"x\"" + + "}}"; + + doConversion(json); + } + + @Test(expectedExceptions={ IllegalArgumentException.class }) + public void testSingleTagWeirdNameWithWhitespace() throws FormatException, IOException { + String json = "{" + + "\"bOdy PaRt examiNeD \": {" + + "\"Value\": \"x\"" + + "}}"; + + doConversion(json); + } + + @Test + public void testSingleTagWeirdName() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"bOdyPaRtexamiNeD\": {" + + "\"Value\": \"BRAIN\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + DicomTag found = lookup(tags, DicomAttribute.BODY_PART_EXAMINED); + assertNotNull(found); + + // trailing space is not a typo + // the writer pads string values to an even number of characters + assertEquals(found.value, "BRAIN "); + assertEquals(found.vr, DicomVR.CS); + } + finally { + Files.delete(dicomFile); + } + } + + @Test + public void testSingleTagCustomVR() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"BodyPartExamined\": {" + + "\"Value\": \"0\"," + + "\"VR\": \"SH\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + DicomTag found = lookup(tags, DicomAttribute.BODY_PART_EXAMINED); + assertNotNull(found); + + assertEquals(found.value, "0 "); + assertEquals(found.vr, DicomVR.SH); + } + finally { + Files.delete(dicomFile); + } + } + + @Test + public void testReplaceExistingTag() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"SpecimenLabelInImage\": {" + + "\"Value\": \"NO\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + DicomTag found = lookup(tags, DicomAttribute.SPECIMEN_LABEL_IN_IMAGE); + assertNotNull(found); + + assertEquals(found.value, "NO"); + assertEquals(found.vr, DicomVR.CS); + } + finally { + Files.delete(dicomFile); + } + } + + @Test + public void testIgnoreExistingTag() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"SpecimenLabelInImage\": {" + + "\"Value\": \"NO\"," + + "\"ResolutionStrategy\": \"IGNORE\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + DicomTag found = lookup(tags, DicomAttribute.SPECIMEN_LABEL_IN_IMAGE); + assertNotNull(found); + + assertEquals(found.value, "YES "); + assertEquals(found.vr, DicomVR.CS); + } + finally { + Files.delete(dicomFile); + } + } + + @Test + public void testReplaceOpticalPath() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"OpticalPathSequence\": {" + + "\"Sequence\": {" + + "\"IlluminationTypeCodeSequence\": {" + + "\"Sequence\": {" + + "\"CodeValue\": {" + + "\"VR\": \"SH\"," + + "\"Value\": \"111743\"" + + "}," + + "\"CodingSchemeDesignator\": {" + + "\"VR\": \"SH\"," + + "\"Value\": \"DCM\"" + + "}," + + "\"CodeMeaning\": {" + + "\"VR\": \"LO\"," + + "\"Tag\": \"(0008,0104)\"," + + "\"Value\": \"Epifluorescence illumination\"" + + "}}}," + + "\"IlluminationWaveLength\": {" + + "\"Value\": \"488.0\"" + + "}," + + "\"OpticalPathIdentifier\": {" + + "\"Value\": \"1\"" + + "}," + + "\"OpticalPathDescription\": {" + + "\"Value\": \"replacement channel\" " + + "}}," + + "\"ResolutionStrategy\": \"REPLACE\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + + DicomTag found = lookup(tags, DicomAttribute.OPTICAL_PATH_SEQUENCE); + assertNotNull(found); + + assertEquals(found.children.size(), 4); + assertEquals(found.vr, DicomVR.SQ); + + assertEquals(found.children.get(0).attribute, DicomAttribute.ILLUMINATION_TYPE_CODE_SEQUENCE); + + assertEquals(found.children.get(1).attribute, DicomAttribute.ILLUMINATION_WAVELENGTH); + assertEquals(found.children.get(1).value, 488f); + + assertEquals(found.children.get(2).attribute, DicomAttribute.OPTICAL_PATH_ID); + assertEquals(found.children.get(2).value, "1 "); + + assertEquals(found.children.get(3).attribute, DicomAttribute.OPTICAL_PATH_DESCRIPTION); + assertEquals(found.children.get(3).value, "replacement channel "); + } + finally { + Files.delete(dicomFile); + } + } + + @Test + public void testAppendOpticalPath() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"OpticalPathSequence\": {" + + "\"Sequence\": {" + + "\"IlluminationTypeCodeSequence\": {" + + "\"Sequence\": {" + + "\"CodeValue\": {" + + "\"VR\": \"SH\"," + + "\"Value\": \"111743\"" + + "}," + + "\"CodingSchemeDesignator\": {" + + "\"VR\": \"SH\"," + + "\"Value\": \"DCM\"" + + "}," + + "\"CodeMeaning\": {" + + "\"VR\": \"LO\"," + + "\"Tag\": \"(0008,0104)\"," + + "\"Value\": \"Epifluorescence illumination\"" + + "}}}," + + "\"IlluminationWaveLength\": {" + + "\"Value\": \"488.0\"" + + "}," + + "\"OpticalPathIdentifier\": {" + + "\"Value\": \"1\"" + + "}," + + "\"OpticalPathDescription\": {" + + "\"Value\": \"replacement channel\" " + + "}}," + + "\"ResolutionStrategy\": \"APPEND\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + + DicomTag found = lookup(tags, DicomAttribute.OPTICAL_PATH_SEQUENCE); + assertNotNull(found); + + assertEquals(found.children.size(), 8); + assertEquals(found.vr, DicomVR.SQ); + + assertEquals(found.children.get(0).attribute, DicomAttribute.ILLUMINATION_TYPE_CODE_SEQUENCE); + + assertEquals(found.children.get(1).attribute, DicomAttribute.ILLUMINATION_WAVELENGTH); + assertEquals(found.children.get(1).value, 1f); + + assertEquals(found.children.get(2).attribute, DicomAttribute.OPTICAL_PATH_ID); + assertEquals(found.children.get(2).value, "0 "); + + assertEquals(found.children.get(3).attribute, DicomAttribute.OPTICAL_PATH_DESCRIPTION); + assertEquals(found.children.get(3).value, ""); + + assertEquals(found.children.get(4).attribute, DicomAttribute.ILLUMINATION_TYPE_CODE_SEQUENCE); + + assertEquals(found.children.get(5).attribute, DicomAttribute.ILLUMINATION_WAVELENGTH); + assertEquals(found.children.get(5).value, 488f); + + assertEquals(found.children.get(6).attribute, DicomAttribute.OPTICAL_PATH_ID); + assertEquals(found.children.get(6).value, "1 "); + + assertEquals(found.children.get(7).attribute, DicomAttribute.OPTICAL_PATH_DESCRIPTION); + assertEquals(found.children.get(7).value, "replacement channel "); + } + finally { + Files.delete(dicomFile); + } + } + + @Test + public void testIgnoreOpticalPath() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"OpticalPathSequence\": {" + + "\"Sequence\": {" + + "\"IlluminationTypeCodeSequence\": {" + + "\"Sequence\": {" + + "\"CodeValue\": {" + + "\"VR\": \"SH\"," + + "\"Value\": \"111743\"" + + "}," + + "\"CodingSchemeDesignator\": {" + + "\"VR\": \"SH\"," + + "\"Value\": \"DCM\"" + + "}," + + "\"CodeMeaning\": {" + + "\"VR\": \"LO\"," + + "\"Tag\": \"(0008,0104)\"," + + "\"Value\": \"Epifluorescence illumination\"" + + "}}}," + + "\"IlluminationWaveLength\": {" + + "\"Value\": \"488.0\"" + + "}," + + "\"OpticalPathIdentifier\": {" + + "\"Value\": \"1\"" + + "}," + + "\"OpticalPathDescription\": {" + + "\"Value\": \"replacement channel\" " + + "}}," + + "\"ResolutionStrategy\": \"IGNORE\"" + + "}}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + + DicomTag found = lookup(tags, DicomAttribute.OPTICAL_PATH_SEQUENCE); + assertNotNull(found); + + assertEquals(found.children.size(), 4); + assertEquals(found.vr, DicomVR.SQ); + + assertEquals(found.children.get(0).attribute, DicomAttribute.ILLUMINATION_TYPE_CODE_SEQUENCE); + + assertEquals(found.children.get(1).attribute, DicomAttribute.ILLUMINATION_WAVELENGTH); + assertEquals(found.children.get(1).value, 1f); + + assertEquals(found.children.get(2).attribute, DicomAttribute.OPTICAL_PATH_ID); + assertEquals(found.children.get(2).value, "0 "); + + assertEquals(found.children.get(3).attribute, DicomAttribute.OPTICAL_PATH_DESCRIPTION); + assertEquals(found.children.get(3).value, ""); + } + finally { + Files.delete(dicomFile); + } + } + + @Test + public void testIgnoreInvalidJSON() throws FormatException, IOException { + Path dicomFile = null; + try { + String json = "{" + + "\"BodyPartExamined\": {" + + "\"Value\": \"BRAIN\"" + + "}"; + + dicomFile = doConversion(json); + List tags = getTags(dicomFile); + DicomTag found = lookup(tags, DicomAttribute.BODY_PART_EXAMINED); + assertEquals(found, null); + } + finally { + Files.delete(dicomFile); + } + } + +} diff --git a/components/formats-bsd/test/loci/formats/utests/testng.xml b/components/formats-bsd/test/loci/formats/utests/testng.xml index 1942d83acbf..95bec190a62 100644 --- a/components/formats-bsd/test/loci/formats/utests/testng.xml +++ b/components/formats-bsd/test/loci/formats/utests/testng.xml @@ -193,4 +193,10 @@ + + + + + +