Mercurial > hg > orthanc-java
changeset 28:43923934e934
added sample: deep learning for mammography
author | Sebastien Jodogne <s.jodogne@gmail.com> |
---|---|
date | Wed, 12 Jun 2024 13:58:29 +0200 |
parents | 4a750ca9461e |
children | 118adbad648f |
files | .hgignore .reuse/dep5 Samples/MammographyDeepLearning/configuration.json Samples/MammographyDeepLearning/pom.xml Samples/MammographyDeepLearning/src/main/java/Detection.java Samples/MammographyDeepLearning/src/main/java/DicomCode.java Samples/MammographyDeepLearning/src/main/java/DicomToolbox.java Samples/MammographyDeepLearning/src/main/java/ImageProcessing.java Samples/MammographyDeepLearning/src/main/java/Main.java Samples/MammographyDeepLearning/src/main/java/OrthancConnection.java Samples/MammographyDeepLearning/src/main/java/Rectangle.java Samples/MammographyDeepLearning/src/main/java/RetinaNet.java Samples/MammographyDeepLearning/src/main/resources/OrthancExplorer.js |
diffstat | 13 files changed, 1737 insertions(+), 1 deletions(-) [+] |
line wrap: on
line diff
--- a/.hgignore Tue Jun 11 19:14:51 2024 +0200 +++ b/.hgignore Wed Jun 12 13:58:29 2024 +0200 @@ -19,4 +19,12 @@ Samples/Dcm4Che/target/ Samples/FHIR/.idea/ Samples/FHIR/OrthancStorage/ +Samples/FHIR/libOrthancDicomWeb.so Samples/FHIR/target/ +Samples/MammographyDeepLearning/.idea/ +Samples/MammographyDeepLearning/2024-03-08-retina_res50_trained_08_03.torchscript +Samples/MammographyDeepLearning/2024-03-15-StoneWebViewer-DICOM-SR.zip +Samples/MammographyDeepLearning/OrthancStorage/ +Samples/MammographyDeepLearning/dependency-reduced-pom.xml +Samples/MammographyDeepLearning/libOrthancDicomWeb.so +Samples/MammographyDeepLearning/target/
--- a/.reuse/dep5 Tue Jun 11 19:14:51 2024 +0200 +++ b/.reuse/dep5 Wed Jun 12 13:58:29 2024 +0200 @@ -3,7 +3,7 @@ Upstream-Contact: Sebastien Jodogne <s.jodogne@gmail.com> Source: https://orthanc.uclouvain.be/ -Files: NEWS README Samples/Basic/NOTES.txt Samples/Dcm4Che/NOTES.txt Samples/FHIR/NOTES.txt Samples/FHIR/configuration.json +Files: NEWS README Samples/Basic/NOTES.txt Samples/Dcm4Che/NOTES.txt Samples/FHIR/NOTES.txt Samples/FHIR/configuration.json Samples/MammographyDeepLearning/NOTES.txt Samples/MammographyDeepLearning/configuration.json Copyright: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium License: CC0-1.0
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/configuration.json Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,11 @@ +{ + "Plugins" : [ + "../../Plugin/Build", + "./libOrthancDicomWeb.so" + ], + "Java" : { + "Enabled" : true, + "Classpath" : "target/OrthancMammographyDeepLearning-mainline.jar", + "InitializationClass" : "Main" + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/pom.xml Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,162 @@ +<?xml version='1.0' encoding='UTF-8' ?> + +<!-- + + SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + SPDX-License-Identifier: GPL-3.0-or-later + +--> + +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jar-plugin</artifactId> + <configuration> + <archive> + <manifest> + <mainClass>Main</mainClass> + </manifest> + </archive> + </configuration> + </plugin> + + <!-- Maven Shade plugin - for creating the uberjar / fatjar --> + <!-- see http://maven.apache.org/plugins/maven-shade-plugin/index.html for details --> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-shade-plugin</artifactId> + <configuration> + <transformers> + <transformer + implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/> + </transformers> + </configuration> + <executions> + <execution> + <phase>package</phase> + <goals> + <goal>shade</goal> + </goals> + </execution> + </executions> + </plugin> + + <!-- Include the Orthanc Java SDK --> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>build-helper-maven-plugin</artifactId> + <executions> + <execution> + <id>add-source</id> + <phase>generate-sources</phase> + <goals> + <goal>add-source</goal> + </goals> + <configuration> + <sources> + <source>${project.basedir}/../../JavaSDK/be/uclouvain/orthanc</source> + </sources> + </configuration> + </execution> + </executions> + </plugin> + + </plugins> + </build> + + <groupId>OrthancMammographyDeepLearning</groupId> + <artifactId>OrthancMammographyDeepLearning</artifactId> + <version>mainline</version> + + <properties> + <maven.compiler.source>11</maven.compiler.source> + <maven.compiler.target>11</maven.compiler.target> + <djl.version>0.26.0</djl.version> + <exec.mainClass>Main</exec.mainClass> + </properties> + + <repositories> + <repository> + <id>djl.ai</id> + <url>https://oss.sonatype.org/content/repositories/snapshots/</url> + </repository> + </repositories> + + <dependencyManagement> + <dependencies> + <dependency> + <groupId>ai.djl</groupId> + <artifactId>bom</artifactId> + <version>${djl.version}</version> + <type>pom</type> + <scope>import</scope> + </dependency> + </dependencies> + </dependencyManagement> + + <dependencies> + <dependency> + <groupId>ai.djl</groupId> + <artifactId>api</artifactId> + </dependency> + <dependency> + <groupId>ai.djl.pytorch</groupId> + <artifactId>pytorch-engine</artifactId> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>ai.djl.pytorch</groupId> + <artifactId>pytorch-jni</artifactId> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>ai.djl.pytorch</groupId> + <artifactId>pytorch-model-zoo</artifactId> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-simple</artifactId> + <version>1.7.36</version> + <scope>runtime</scope> + </dependency> + + <!-- Include PyTorch native library for platforms --> + <dependency> + <groupId>ai.djl.pytorch</groupId> + <artifactId>pytorch-native-cpu</artifactId> + <classifier>linux-x86_64</classifier> + <scope>runtime</scope> + </dependency> + <!--dependency> + <groupId>ai.djl.pytorch</groupId> + <artifactId>pytorch-native-cpu</artifactId> + <classifier>win-x86_64</classifier> + <scope>runtime</scope> + </dependency--> + <!--dependency> + <groupId>ai.djl.pytorch</groupId> + <artifactId>pytorch-native-cpu</artifactId> + <classifier>osx-x86_64</classifier> + <scope>runtime</scope> + </dependency--> + <!--dependency> + <groupId>ai.djl.pytorch</groupId> + <artifactId>pytorch-native-cpu</artifactId> + <classifier>osx-aarch64</classifier> + <scope>runtime</scope> + </dependency--> + + <dependency> + <groupId>org.json</groupId> + <artifactId>json</artifactId> + <version>20240303</version> + </dependency> + + </dependencies> +</project>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/Detection.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import java.io.Serializable; + +class Detection implements Comparable<Detection>, Serializable { + private Rectangle rectangle; + private int label; + private double score; + + public Detection(Rectangle rectangle, + int label, + double score) { + this.rectangle = rectangle; + this.label = label; + this.score = score; + } + + public Rectangle getRectangle() { + return rectangle; + } + + public int getLabel() { + return label; + } + + public double getScore() { + return score; + } + + @Override + public int compareTo(Detection peak) { + return Double.compare(peak.score, score); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/DicomCode.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,62 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import org.json.JSONObject; + +class DicomCode { + private static final String CODE_VALUE = "0008,0100"; + private static final String CODING_SCHEME_DESIGNATOR = "0008,0102"; + private static final String CODE_MEANING = "0008,0104"; + + private String codeValue; + private String codingSchemeDesignator; + private String codeMeaning; + + public DicomCode(String codeValue, + String codingSchemeDesignator, + String codeMeaning) { + this.codeValue = codeValue; + this.codingSchemeDesignator = codingSchemeDesignator; + this.codeMeaning = codeMeaning; + } + + public String getCodeValue() { + return codeValue; + } + + public String getCodingSchemeDesignator() { + return codingSchemeDesignator; + } + + public String getCodeMeaning() { + return codeMeaning; + } + + public JSONObject toJson() { + return new JSONObject(). + put(CODE_VALUE, codeValue). + put(CODING_SCHEME_DESIGNATOR, codingSchemeDesignator). + put(CODE_MEANING, codeMeaning); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/DicomToolbox.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,287 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.List; + +public class DicomToolbox { + private static final String MAPPING_RESOURCE = "0008,0105"; + private static final String REFERENCED_SOP_CLASS_UID = "0008,1150"; + private static final String REFERENCED_SOP_INSTANCE_UID = "0008,1155"; + private static final String REFERENCED_SOP_SEQUENCE = "0008,1199"; + + private static final String CONTENT_TEMPLATE_SEQUENCE = "0040,a504"; + private static final String TEMPLATE_IDENTIFIER = "0040,db00"; + private static final String RELATIONSHIP_TYPE = "0040,a010"; + private static final String CONCEPT_NAME_CODE_SEQUENCE = "0040,a043"; + private static final String VALUE_TYPE = "0040,a040"; + private static final String CONCEPT_CODE_SEQUENCE = "0040,a168"; + private static final String CONTINUITY_OF_CONTENT = "0040,a050"; + private static final String CONTENT_SEQUENCE = "0040,a730"; + private static final String TEXT_VALUE_ATTRIBUTE = "0040,a160"; + private static final String UID_ATTRIBUTE = "0040,a124"; + private static final String MEASURED_VALUE_SEQUENCE = "0040,a300"; + private static final String MEASUREMENT_UNITS_CODE_SEQUENCE = "0040,08ea"; + private static final String FLOATING_POINT_VALUE = "0040,a161"; + private static final String NUMERIC_VALUE = "0040,a30a"; + private static final String GRAPHIC_DATA = "0070,0022"; + private static final String GRAPHIC_TYPE = "0070,0023"; + + static JSONObject createDicomSR(OrthancConnection client, + String instance, + List<Detection> detections, + double aspectRatio) throws IOException, InterruptedException { + final JSONObject instanceTags = client.doGetAsJsonObject("/instances/" + instance + "/tags?short"); + final String trackingUid = client.doGetAsString("/tools/generate-uid?level=instance"); + + String instanceStudy = client.doGetAsJsonObject("/instances/" + instance + "/study").getString("ID"); + + // https://dicom.nema.org/medical/dicom/2024a/output/chtml/part16/chapter_A.html#sect_TID_1500 + // Sample TID 1500: https://dicom.nema.org/medical/dicom/current/output/chtml/part21/sect_a.7.2.html + + + JSONObject json = new JSONObject(); + json.put("Parent", instanceStudy); + + JSONObject tags = new JSONObject(); + tags.put("0008,0016", "1.2.840.10008.5.1.4.1.1.88.33"); // SOP Class UID + tags.put("0008,0060", "SR"); // Modality + tags.put("0008,0070", ""); // Manufacturer + tags.put("0008,1111", new JSONArray()); // Referenced Performed Procedure Step Sequence is type 2 (required but can be empty) + tags.put("0020,0011", "1"); // Series number + tags.put("0020,0013", "1"); // Instance number + tags.put("0040,a040", "CONTAINER"); // Value type for SR: https://dicom.nema.org/medical/dicom/2024a/output/chtml/part03/sect_C.17.3.html + + // CID 7021 Measurement Report Document Title: https://dicom.nema.org/medical/dicom/2024a/output/chtml/part16/sect_CID_7021.html + tags.append("0040,a043", new DicomCode("126000", "DCM", "Imaging Measurement Report").toJson()); + + tags.put("0040,a050", "CONTINUOUS"); // Continuity of content + tags.put("0040,a372", new JSONArray()); // Performed procedure code sequence + + // Current Requested Procedure Evidence: References to the StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID, and SOPClassUID + tags.append("0040,a375", new JSONObject(). + put("0020,000d", instanceTags.getString("0020,000d")). + append("0008,1115", new JSONObject(). + put("0020,000e", instanceTags.getString("0020,000e")). + append("0008,1199", new JSONObject(). + put("0008,1150", instanceTags.getString("0008,0016")). + put("0008,1155", instanceTags.getString("0008,0018"))))); + + tags.put("0040,a491", "PARTIAL"); // Completion flag + tags.put("0040,a493", "UNVERIFIED"); // Verification flag + tags.put("0040,a496", "PRELIMINARY"); // Preliminary flag + + // Content template sequence, which indicates TID 1500 + setTemplateId(tags, "1500"); + + JSONArray contentSequence = new JSONArray(); + contentSequence.put(createCodeRelationship( + new DicomCode("121049", "DCM", "Language of Content Item and Descendants"), + new DicomCode("en-US", "RFC5646", "English (United States)"))); + + // This is "procedure_reported" in "highdicom.sr.MeasurementReport()" + contentSequence.put(createCodeRelationship( + new DicomCode("121058", "DCM", "Procedure reported"), + new DicomCode("43468-8", "LN", "XR unspecified body region"))); + + JSONArray measurementGroups = new JSONArray(); + + for (Detection detection : detections) { + JSONArray measurements = new JSONArray(); + measurements.put(createTextContext( + new DicomCode("112039", "DCM", "Tracking Identifier"), + "Orthanc Deep Learning for Mammography")); + + measurements.put(createUidReference( + new DicomCode("112040", "DCM", "Tracking Unique Identifier"), + trackingUid)); + + measurements.put(createNumericValue( + new DicomCode("111047", "DCM", "Probability of cancer"), + new DicomCode("%", "UCUM", "Percent"), + detection.getScore() * 100.0)); + + measurements.put(createRectangle( + new DicomCode("111030", "DCM", "Image Region"), + instanceTags.getString("0008,0016"), instanceTags.getString("0008,0018"), + detection.getRectangle(), aspectRatio)); + + JSONObject measurementGroup = createContainer(new DicomCode("125007", "DCM", "Measurement Group"), measurements); + setTemplateId(measurementGroup, "1410"); + + measurementGroups.put(measurementGroup); + } + + JSONObject imagingMeasurements = createContainer(new DicomCode("126010", "DCM", "Imaging Measurements"), measurementGroups); + contentSequence.put(imagingMeasurements); + + tags.put("0040,a730", contentSequence); + + json.put("Tags", tags); + + return client.doPostAsJsonObject("/tools/create-dicom", json.toString()); + } + + static public class Point2D { + private double x; + private double y; + + public Point2D(double x, + double y) { + this.x = x; + this.y = y; + } + + public double getX() { + return x; + } + + public double getY() { + return y; + } + } + + public static JSONObject setTemplateId(JSONObject target, + String templateId) { + if (target.has(CONTENT_TEMPLATE_SEQUENCE)) { + throw new IllegalStateException(); + } else { + target.append(CONTENT_TEMPLATE_SEQUENCE, new JSONObject(). + put(MAPPING_RESOURCE, "DCMR"). + put(TEMPLATE_IDENTIFIER, templateId)); + return target; + } + } + + public static JSONObject createCodeRelationship(DicomCode concept, + DicomCode referencedCode) { + JSONObject result = new JSONObject(); + result.put(RELATIONSHIP_TYPE, "HAS CONCEPT MOD"); + result.append(CONCEPT_NAME_CODE_SEQUENCE, concept.toJson()); + result.put(VALUE_TYPE, "CODE"); + result.append(CONCEPT_CODE_SEQUENCE, referencedCode.toJson()); + return result; + } + + public static JSONObject createContainer(DicomCode concept, + JSONArray content) { + JSONObject result = new JSONObject(); + result.put(RELATIONSHIP_TYPE, "CONTAINS"); + result.put(VALUE_TYPE, "CONTAINER"); + result.append(CONCEPT_NAME_CODE_SEQUENCE, concept.toJson()); + result.put(CONTINUITY_OF_CONTENT, "CONTINUOUS"); + result.put(CONTENT_SEQUENCE, content); + return result; + } + + public static JSONObject createTextContext(DicomCode concept, + String value) { + JSONObject result = new JSONObject(); + result.put(RELATIONSHIP_TYPE, "HAS OBS CONTEXT"); + result.put(VALUE_TYPE, "TEXT"); + result.append(CONCEPT_NAME_CODE_SEQUENCE, concept.toJson()); + result.put(TEXT_VALUE_ATTRIBUTE, value); + return result; + } + + public static JSONObject createUidReference(DicomCode concept, + String uid) { + JSONObject result = new JSONObject(); + result.put(RELATIONSHIP_TYPE, "HAS OBS CONTEXT"); + result.put(VALUE_TYPE, "UIDREF"); + result.append(CONCEPT_NAME_CODE_SEQUENCE, concept.toJson()); + result.put(UID_ATTRIBUTE, uid); + return result; + } + + public static JSONObject createNumericValue(DicomCode concept, + DicomCode unit, + double value) { + String ds = String.valueOf(value); + if (ds.length() > 16) { + ds = ds.substring(0, 16); // The DS VR must have less than 16 characters + } + + JSONObject result = new JSONObject(); + result.put(RELATIONSHIP_TYPE, "CONTAINS"); + result.put(VALUE_TYPE, "NUM"); + result.append(CONCEPT_NAME_CODE_SEQUENCE, concept.toJson()); + result.append(MEASURED_VALUE_SEQUENCE, new JSONObject(). + append(MEASUREMENT_UNITS_CODE_SEQUENCE, unit.toJson()). + put(FLOATING_POINT_VALUE, String.valueOf(value)). + put(NUMERIC_VALUE, ds)); + return result; + } + + public static JSONObject createSelectedFromImage(String sopClassUid, + String sopInstanceUid) { + JSONObject result = new JSONObject(); + result.put(RELATIONSHIP_TYPE, "SELECTED FROM"); + result.put(VALUE_TYPE, "IMAGE"); + result.append(REFERENCED_SOP_SEQUENCE, new JSONObject(). + put(REFERENCED_SOP_CLASS_UID, sopClassUid). + put(REFERENCED_SOP_INSTANCE_UID, sopInstanceUid)); + result.append(CONCEPT_NAME_CODE_SEQUENCE, new DicomCode("111040", "DCM", "Original Source").toJson()); + return result; + } + + public static JSONObject createPolyline2D(DicomCode concept, + String sopClassUid, + String sopInstanceUid, + Point2D vertices[]) { + String polyline = new String(); + for (int i = 0; i < vertices.length; i++) { + if (!polyline.isEmpty()) { + polyline = polyline + "\\"; + } + polyline += String.valueOf(vertices[i].getX()) + "\\" + String.valueOf(vertices[i].getY()); + } + + JSONObject result = new JSONObject(); + result.put(RELATIONSHIP_TYPE, "CONTAINS"); + result.put(VALUE_TYPE, "SCOORD"); + result.append(CONCEPT_NAME_CODE_SEQUENCE, concept.toJson()); + result.append(CONTENT_SEQUENCE, createSelectedFromImage(sopClassUid, sopInstanceUid)); + result.put(GRAPHIC_DATA, polyline); // WARNING: This necessitates Orthanc > 1.12.4: https://orthanc.uclouvain.be/hg/orthanc/rev/dedbf019a707 + result.put(GRAPHIC_TYPE, "POLYLINE"); + return result; + } + + public static JSONObject createRectangle(DicomCode concept, + String sopClassUid, + String sopInstanceUid, + Rectangle rectangle, + double aspectRatio) { + Point2D vertices[] = new Point2D[5]; + vertices[0] = new Point2D(rectangle.getX1() * aspectRatio, rectangle.getY1() * aspectRatio); + vertices[1] = new Point2D(rectangle.getX2() * aspectRatio, rectangle.getY1() * aspectRatio); + vertices[2] = new Point2D(rectangle.getX2() * aspectRatio, rectangle.getY2() * aspectRatio); + vertices[3] = new Point2D(rectangle.getX1() * aspectRatio, rectangle.getY2() * aspectRatio); + vertices[4] = new Point2D(rectangle.getX1() * aspectRatio, rectangle.getY1() * aspectRatio); + return createPolyline2D(concept, sopClassUid, sopInstanceUid, vertices); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/ImageProcessing.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import ai.djl.ndarray.NDArray; +import ai.djl.ndarray.NDManager; +import ai.djl.ndarray.types.DataType; +import ai.djl.ndarray.types.Shape; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; + +public class ImageProcessing { + static BufferedImage resizeImage(BufferedImage originalImage, int targetWidth, int targetHeight) { + BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, originalImage.getType()); + Graphics2D graphics2D = resizedImage.createGraphics(); + graphics2D.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics2D.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null); + graphics2D.dispose(); + return resizedImage; + } + + static NDArray imageToTensor(NDManager manager, + BufferedImage image) { + if (image.getType() != image.TYPE_USHORT_GRAY /* 16 bpp */ && + image.getType() != image.TYPE_BYTE_GRAY /* 8 bpp */) { + throw new IllegalArgumentException(); + } + + float pixels[] = new float[image.getHeight() * image.getWidth()]; + + DataBuffer db = image.getData().getDataBuffer(); + + int pos = 0; + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++, pos++) { + pixels[pos] = db.getElemFloat(pos); + } + } + + return manager.create(pixels, new Shape(1, image.getHeight(), image.getWidth())); + } + + static NDArray standardize(NDArray image) { + if (image.getDataType() != DataType.FLOAT32 || + image.getShape().dimension() != 3 || + (image.getShape().get(0) != 1 && + image.getShape().get(0) != 3)) { + throw new IllegalArgumentException(); + } + + // Standardize the image to zero mean and 1 standard deviation + NDArray doubleImage = image.toType(DataType.FLOAT64, false); + NDArray squared = doubleImage.mul(doubleImage); + + double asum = doubleImage.sum().getDouble(); + double asumOfSquares = squared.sum().getDouble(); + double n = doubleImage.getShape().size(); + double amean = asum / n; + double astd = Math.sqrt((asumOfSquares - asum * asum / n) / n); + + return image.add(-amean).div(astd); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/Main.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,230 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import ai.djl.ndarray.NDArray; +import ai.djl.ndarray.NDManager; +import be.uclouvain.orthanc.Callbacks; +import be.uclouvain.orthanc.ChangeType; +import be.uclouvain.orthanc.Functions; +import be.uclouvain.orthanc.HttpMethod; +import be.uclouvain.orthanc.ResourceType; +import be.uclouvain.orthanc.RestOutput; +import org.apache.commons.compress.utils.IOUtils; +import org.json.JSONObject; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class Main { + private static final String MODEL_PATH = "2024-03-08-retina_res50_trained_08_03.torchscript"; + private static final String STONE_VERSION = "2024-03-15-StoneWebViewer-DICOM-SR"; + private static final String STONE_PATH = "2024-03-15-StoneWebViewer-DICOM-SR.zip"; + + private static ZipFile stone; + private static NDManager manager; + private static RetinaNet retinaNet; + private static Map<String, String> mimeTypes = new HashMap<>(); + + static { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + OrthancConnection.download("2024-03-08-retina_res50_trained_08_03.torchscript", executor, + "https://orthanc.uclouvain.be/downloads/cross-platform/orthanc-mammography/models/2024-03-08-retina_res50_trained_08_03.torchscript", + 146029397L, "b3de8f562de683bc3515fe93ae102fd4"); + OrthancConnection.download("2024-03-15-StoneWebViewer-DICOM-SR.zip", executor, + "https://github.com/jodogne/orthanc-mammography/raw/master/viewer/2024-03-15-StoneWebViewer-DICOM-SR.zip", + 4742571L, "de952da6fc74a9d4b78ca5064a6a7318"); + } catch (IOException | NoSuchAlgorithmException | InterruptedException e) { + throw new RuntimeException(e); + } finally { + executor.shutdown(); + } + + try { + stone = new ZipFile(STONE_PATH); + } catch (IOException e) { + throw new RuntimeException(e); + } + + Functions.logWarning("Initializing Deep Java Library"); + manager = NDManager.newBaseManager(); + + Functions.logWarning("Loading RetinaNet model"); + try { + retinaNet = new RetinaNet(MODEL_PATH); + } catch (Exception e) { + throw new RuntimeException(e); + } + + Functions.logWarning("RetinaNet model is ready"); + + mimeTypes.put("css", "text/css"); + mimeTypes.put("gif", "image/gif"); + mimeTypes.put("html", "text/html"); + mimeTypes.put("jpeg", "image/jpeg"); + mimeTypes.put("js", "text/javascript"); + mimeTypes.put("png", "image/png"); + + try (InputStream stream = Main.class.getResourceAsStream("OrthancExplorer.js")) { + byte[] content = IOUtils.toByteArray(stream); + Functions.extendOrthancExplorer(new String(content, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new RuntimeException(e); + } + + Callbacks.register(new Callbacks.OnChange() { + @Override + public void call(ChangeType changeType, ResourceType resourceType, String resourceId) { + switch (changeType) { + case ORTHANC_STARTED: + OrthancConnection connection = OrthancConnection.createForPlugin(); + try { + if (!connection.isOrthancVersionAbove(1, 12, 5)) { + throw new RuntimeException("Your version of Orthanc must be >= 1.12.5 to run this plugin"); + } + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + + break; + + case ORTHANC_STOPPED: + Functions.logWarning("Finalizing Deep Java Library"); + if (retinaNet != null) { + retinaNet.close(); + } + + if (manager != null) { + manager.close(); + } + + System.gc(); + System.runFinalization(); + break; + + default: + break; + } + } + }); + + Callbacks.register("/java-mammography-apply", new Callbacks.OnRestRequest() { + @Override + public void call(RestOutput output, + HttpMethod method, + String uri, + String[] regularExpressionGroups, + Map<String, String> headers, + Map<String, String> getParameters, + byte[] body) { + if (method != HttpMethod.POST) { + output.sendMethodNotAllowed("POST"); + return; + } + + JSONObject request = new JSONObject(new String(body, StandardCharsets.UTF_8)); + + String instanceId = request.getString("instance"); + if (instanceId == null) { + throw new RuntimeException("Missing instance identifier"); + } + + Functions.logWarning("Applying RetinaNet to instance: " + instanceId); + OrthancConnection connection = OrthancConnection.createForPlugin(); + + try { + BufferedImage image = connection.getGrayscaleFrame(instanceId, 0); + + double largestSide = Math.max(image.getWidth(), image.getHeight()); + BufferedImage resized = ImageProcessing.resizeImage(image, + (int) Math.round((double) image.getWidth() * 2048.0 / largestSide), + (int) Math.round((double) image.getHeight() * 2048.0 / largestSide)); + + double aspectRatio = (double) image.getWidth() / (double) resized.getWidth(); + + NDArray grayscale = ImageProcessing.imageToTensor(manager, resized); + grayscale = ImageProcessing.standardize(grayscale); + + NDArray rgb = grayscale.concat(grayscale).concat(grayscale); // Create a "RGB" image from grayscale pixel values + List<Detection> detections = retinaNet.apply(rgb); + + JSONObject created = DicomToolbox.createDicomSR(connection, instanceId, detections, aspectRatio); + + String id = created.getString("ID"); + Functions.logWarning("Detection results stored in DICOM-SR instance: " + id); + + output.answerBuffer(created.toString().getBytes(StandardCharsets.UTF_8), "application/json"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + + Callbacks.register("/java-mammography-viewer/(.*)", new Callbacks.OnRestRequest() { + @Override + public void call(RestOutput output, + HttpMethod method, + String uri, + String[] regularExpressionGroups, + Map<String, String> headers, + Map<String, String> getParameters, + byte[] body) { + if (method != HttpMethod.GET) { + output.sendMethodNotAllowed("GET"); + return; + } + + String path = regularExpressionGroups[0]; + int dot = path.lastIndexOf("."); + if (dot < 0) { + output.sendHttpStatus((short) 404, new byte[0]); + } else { + String extension = path.substring(dot + 1); + String mime = mimeTypes.getOrDefault(extension, "application/octet-stream"); + + ZipEntry entry = stone.getEntry(STONE_VERSION + "/" + path); + if (entry == null) { + output.sendHttpStatus((short) 404, new byte[0]); + } else { + try (InputStream stream = stone.getInputStream(entry)) { + output.answerBuffer(IOUtils.toByteArray(stream), mime); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + } + }); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/OrthancConnection.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,277 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import org.json.JSONObject; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; + +public abstract class OrthancConnection { + private static final String PIXEL_REPRESENTATION = "0028,0103"; + private static final String BITS_STORED = "0028,0101"; + private static final String SAMPLES_PER_PIXEL = "0028,0002"; + + public static OrthancConnection createHttpClient(ExecutorService executor, + String baseUrl) { + return new OrthancConnection() { + private HttpClient client = HttpClient.newBuilder().executor(executor).build(); + + @Override + public byte[] doGetAsByteArray(String uri) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + uri)) + .build(); + HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() != 200) { + throw new RuntimeException(); + } else { + return response.body(); + } + } + + @Override + public byte[] doPostAsByteArray(String uri, byte[] body) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + uri)) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() != 200) { + throw new RuntimeException(); + } else { + return response.body(); + } + } + }; + } + + public static OrthancConnection createForPlugin() { + return new OrthancConnection() { + @Override + public byte[] doGetAsByteArray(String uri) throws IOException, InterruptedException { + return be.uclouvain.orthanc.Functions.restApiGet(uri); + } + + @Override + public byte[] doPostAsByteArray(String uri, byte[] body) throws IOException, InterruptedException { + return be.uclouvain.orthanc.Functions.restApiPost(uri, body); + } + }; + } + + public abstract byte[] doGetAsByteArray(String uri) throws IOException, InterruptedException; + + public abstract byte[] doPostAsByteArray(String uri, + byte[] body) throws IOException, InterruptedException; + + public String doGetAsString(String uri) throws IOException, InterruptedException { + return new String(doGetAsByteArray(uri), StandardCharsets.UTF_8); + } + + public JSONObject doGetAsJsonObject(String uri) throws IOException, InterruptedException { + return new JSONObject(doGetAsString(uri)); + } + + public String doPostAsString(String uri, + String body) throws IOException, InterruptedException { + byte[] answer = doPostAsByteArray(uri, body.getBytes(StandardCharsets.UTF_8)); + return new String(answer, StandardCharsets.UTF_8); + } + + public JSONObject doPostAsJsonObject(String uri, + String body) throws IOException, InterruptedException { + return new JSONObject(doPostAsString(uri, body)); + } + + public BufferedImage getGrayscaleFrame(String instance, + int frame) throws IOException, InterruptedException { + if (frame < 0) { + throw new IllegalArgumentException(); + } + + JSONObject tags = doGetAsJsonObject("/instances/" + instance + "/tags?short"); + + if (!tags.has(PIXEL_REPRESENTATION) || + tags.getInt(PIXEL_REPRESENTATION) == 1) { + throw new IllegalArgumentException("Negative pixels not supported"); + } + + if (!tags.has(BITS_STORED) || + (tags.getInt(BITS_STORED) != 8 && + tags.getInt(BITS_STORED) != 16)) { + throw new IllegalArgumentException("Pixel depth not supported"); + } + + if (!tags.has(SAMPLES_PER_PIXEL) || + tags.getInt(SAMPLES_PER_PIXEL) != 1) { + throw new IllegalArgumentException("Color images not implemented"); + } + + byte[] png = doGetAsByteArray("/instances/" + instance + "/frames/" + frame + "/image-uint16"); + try (ByteArrayInputStream stream = new ByteArrayInputStream(png)) { + return ImageIO.read(stream); + } + } + + boolean isOrthancVersionAbove(int major, + int minor, + int revision) throws IOException, InterruptedException { + JSONObject system = doGetAsJsonObject("/system"); + String version = system.getString("Version"); + if (version == null) { + throw new RuntimeException("Not an Orthanc server"); + } + + if (version.equals("mainline")) { + return true; + } else { + String[] items = version.split("\\."); + if (items.length != 3) { + throw new RuntimeException("Cannot parse Orthanc version: " + version); + } + + int thisMajor = Integer.valueOf(items[0]); + int thisMinor = Integer.valueOf(items[1]); + int thisRevision = Integer.valueOf(items[2]); + + return (thisMajor > major || + (thisMajor == major && thisMinor > minor) || + (thisMajor == major && thisMinor == minor && thisRevision >= revision)); + } + } + + + public static void download(String targetPath, + ExecutorService executor, + String url, + long expectedSize, + String expectedMd5) throws IOException, NoSuchAlgorithmException, InterruptedException { + class FileDownloader implements HttpResponse.BodyHandler<Void>, Consumer<Optional<byte[]>> { + private FileOutputStream target; + private String url; + private boolean success; + private long contentLength; + private long size; + + public FileDownloader(final FileOutputStream target, + final String url) throws FileNotFoundException { + this.target = target; + this.success = false; + } + + @Override + public HttpResponse.BodySubscriber<Void> apply(final HttpResponse.ResponseInfo responseInfo) { + if (responseInfo.statusCode() != 200) { + throw new RuntimeException("URL does not exist: " + url); + } + + final OptionalLong contentLength = responseInfo.headers().firstValueAsLong("Content-Length"); + if (contentLength.isEmpty()) { + throw new RuntimeException("Server does not provide a content length: " + url); + } + this.contentLength = contentLength.getAsLong(); + + return HttpResponse.BodySubscribers.ofByteArrayConsumer(this); + } + + @Override + public void accept(final Optional<byte[]> bytes) { + if (bytes.isEmpty()) { + System.out.println(); + System.out.flush(); + if (this.success) { + throw new IllegalStateException("File already closed"); + } + if (this.size != this.contentLength) { + throw new RuntimeException("Server has not answered with the proper content length"); + } + this.success = true; + } else { + try { + this.target.write(bytes.get()); + } catch (IOException e) { + System.out.println(); + throw new RuntimeException("Cannot write to file"); + } + this.size += bytes.get().length; + final int BAR_WIDTH = 30; + final int a = Math.min(30, Math.round(this.size / (float) this.contentLength * 30.0f)); + System.out.print("\r Progress: [" + "=".repeat(a) + " ".repeat(30 - a) + "]"); + System.out.flush(); + } + } + + boolean isSuccess() { + return this.success; + } + } + + + System.out.println("Downloading: " + url); + + if (Files.exists(Paths.get(targetPath))) { + System.out.println(" File already downloaded"); + } else { + FileOutputStream target = new FileOutputStream(targetPath); + HttpClient client = HttpClient.newBuilder().executor(executor).followRedirects(HttpClient.Redirect.NORMAL).build(); + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).build(); + + FileDownloader consumer = new FileDownloader(target, url); + HttpResponse<Void> response = client.send(request, (HttpResponse.BodyHandler<Void>) consumer); + target.close(); + + if (!consumer.isSuccess()) { + throw new IOException("Could not download: " + url); + } + } + + byte[] content = Files.readAllBytes(Paths.get(targetPath)); + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(content); + + final String actualMd5 = new BigInteger(1, md.digest()).toString(16); + if (content.length != expectedSize || + !actualMd5.equals(expectedMd5)) { + throw new IOException("Incorrect content in a download file, please remove it and retry: " + targetPath); + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/Rectangle.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import java.io.Serializable; + +class Rectangle implements Serializable { + private float x1; + private float y1; + private float x2; + private float y2; + + public Rectangle(float x1, + float y1, + float x2, + float y2) { + if (x1 > x2) { + throw new IllegalArgumentException(); + } + if (y1 > y2) { + throw new IllegalArgumentException(); + } + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + } + + public float getX1() { + return x1; + } + + public float getY1() { + return y1; + } + + public float getX2() { + return x2; + } + + public float getY2() { + return y2; + } + + public float getWidth() { + return x2 - x1; + } + + public float getHeight() { + return y2 - y1; + } + + public float getArea() { + return getWidth() * getHeight(); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/java/RetinaNet.java Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,348 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +import ai.djl.MalformedModelException; +import ai.djl.inference.Predictor; +import ai.djl.ndarray.NDArray; +import ai.djl.ndarray.NDList; +import ai.djl.repository.zoo.Criteria; +import ai.djl.repository.zoo.ModelNotFoundException; +import ai.djl.repository.zoo.ZooModel; +import ai.djl.training.util.ProgressBar; +import ai.djl.translate.TranslateException; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class RetinaNet { + private ZooModel model; + + private static double sigmoid(double x) { + // This corresponds to "torch.sigmoid()", aka. "torch.expit()" + return 1.0f / (1.0f + Math.exp(-x)); + } + + private static float clamp(float value, + float min, + float max) { + if (value < min) { + return min; + } else if (value > max) { + return max; + } else { + return value; + } + } + + /** + * This function corresponds to method "decode_single()" in class "BoxCoder" in: + * https://github.com/pytorch/vision/blob/main/torchvision/models/detection/_utils.py + */ + public static Rectangle decodeSingle(float[] rel_codes, + float[] boxes, + int image_shape_width, + int image_shape_height) { + /** + * The following constant come from the constructor of "BoxCoder" in: + * https://github.com/pytorch/vision/blob/main/torchvision/models/detection/_utils.py + * + * self, weights: Tuple[float, float, float, float], bbox_xform_clip: float = math.log(1000.0 / 16) + */ + final float BBOX_XFORM_CLIP = (float) Math.log(1000.0 / 16.0); + + /** + * The following constants come from the following line in + * https://github.com/pytorch/vision/blob/main/torchvision/models/detection/retinanet.py + * + * self.box_coder = det_utils.BoxCoder(weights=(1.0, 1.0, 1.0, 1.0)) + */ + final float WX = 1.0f; + final float WY = 1.0f; + final float WW = 1.0f; + final float WH = 1.0f; + + final float widths = boxes[2] - boxes[0]; + final float heights = boxes[3] - boxes[1]; + final float ctr_x = boxes[0] + 0.5f * widths; + final float ctr_y = boxes[1] + 0.5f * heights; + + final float dx = rel_codes[0] / WX; + final float dy = rel_codes[1] / WY; + final float dw = Math.min(rel_codes[2] / WW, BBOX_XFORM_CLIP); // This corresponds to "torch.clamp()" + final float dh = Math.min(rel_codes[3] / WH, BBOX_XFORM_CLIP); + + final float pred_ctr_x = dx * widths + ctr_x; + final float pred_ctr_y = dy * heights + ctr_y; + final float pred_w = (float) (Math.exp(dw) * widths); + final float pred_h = (float) (Math.exp(dh) * heights); + + final float c_to_c_h = 0.5f * pred_h; + final float c_to_c_w = 0.5f * pred_w; + + final float pred_boxes1 = pred_ctr_x - c_to_c_w; + final float pred_boxes2 = pred_ctr_y - c_to_c_h; + final float pred_boxes3 = pred_ctr_x + c_to_c_w; + final float pred_boxes4 = pred_ctr_y + c_to_c_h; + + // The calls to clamp() correspond to function "box_ops.clip_boxes_to_image()" + return new Rectangle( + clamp(pred_boxes1, 0, image_shape_width), + clamp(pred_boxes2, 0, image_shape_height), + clamp(pred_boxes3, 0, image_shape_width), + clamp(pred_boxes4, 0, image_shape_height)); + } + + public RetinaNet(String path) throws ModelNotFoundException, MalformedModelException, IOException { + Criteria criteria = Criteria.builder() + .setTypes(NDList.class, NDList.class) + .optModelPath(Paths.get(path)) + //.optOption("mapLocation", "true") // this model requires mapLocation for GPU + //.optTranslator(translator) + .optProgress(new ProgressBar()).build(); + + model = criteria.loadModel(); + } + + public ZooModel getModel() { + return model; + } + + public void close() { + model.close(); + } + + public List<Detection> apply(NDArray image) throws TranslateException { + if (image.getShape().dimension() != 3 || + image.getShape().get(0) != 3) { + throw new RuntimeException(); + } + + final int imageWidth = (int) image.getShape().get(2); + final int imageHeight = (int) image.getShape().get(1); + + Predictor<NDList, NDList> predictor = model.newPredictor(); + + NDList output = predictor.predict(new NDList(image)); + + if (output.size() <= 3) { + throw new IllegalArgumentException(); + } + + NDArray logits_per_image = output.get(0); + NDArray box_regression_per_image = output.get(1); + NDArray anchors_per_image = output.get(2); + + final int numberOfClasses = (int) logits_per_image.getShape().get(2); + + if (logits_per_image.getShape().dimension() != 3 || + logits_per_image.getShape().get(0) != 1 || + numberOfClasses != 2 /* "2" corresponds to a binary classification task */ || + box_regression_per_image.getShape().dimension() != 3 || + box_regression_per_image.getShape().get(0) != 1 || + box_regression_per_image.getShape().get(2) != 4 || + anchors_per_image.getShape().dimension() != 2 || + anchors_per_image.getShape().get(1) != 4) { + throw new RuntimeException(); + } + + + final int numberOfLevels = output.size() - 3; // This corresponds to "features" in Python export + + int[] num_anchors_per_level = new int[numberOfLevels]; + + { + int[] sizes = new int[numberOfLevels]; + int HW = 0; + + for (int i = 0; i < numberOfLevels; i++) { + NDArray feature = output.get(i + 3); + if (feature.getShape().dimension() != 4 || + feature.getShape().get(0) != 1) { + throw new RuntimeException(); + } + + sizes[i] = (int) (feature.getShape().get(2) * feature.getShape().get(3)); + HW += sizes[i]; + } + + final int HWA = (int) logits_per_image.getShape().get(1); + + if (HWA % HW != 0) { + throw new RuntimeException(); + } + + final int A = (int) (HWA / HW); + + for (int i = 0; i < numberOfLevels; i++) { + num_anchors_per_level[i] = sizes[i] * A; + } + } + + + int anchorsIndex[] = new int[numberOfLevels]; + int countAnchors = 0; + for (int i = 0; i < numberOfLevels; i++) { + anchorsIndex[i] = countAnchors; + countAnchors += num_anchors_per_level[i]; + } + + if (logits_per_image.getShape().get(1) != countAnchors || + box_regression_per_image.getShape().get(1) != countAnchors || + anchors_per_image.getShape().get(0) != countAnchors) { + throw new RuntimeException(); + } + + final float SCORE_THRESHOLD = 0.05f; + final int TOP_K_CANDIDATES = 1000; + + // Convert as float array, because direct access to NDArray is terribly slow + float logits_per_image_as_float[] = logits_per_image.toFloatArray(); + float box_regression_per_image_as_float[] = box_regression_per_image.toFloatArray(); + float anchors_per_image_as_float[] = anchors_per_image.toFloatArray(); + + List<Detection> detections = new LinkedList<>(); + + for (int level = 0; level < anchorsIndex.length; level++) { + float box_regression_per_level[][] = new float[num_anchors_per_level[level]][4]; + float logits_per_level[][] = new float[num_anchors_per_level[level]][2]; + float anchors_per_level[][] = new float[num_anchors_per_level[level]][4]; + + for (int i = 0; i < num_anchors_per_level[level]; i++) { + int index = anchorsIndex[level] + i; + for (int j = 0; j < 4; j++) { + box_regression_per_level[i][j] = box_regression_per_image_as_float[4 * index + j]; + } + for (int j = 0; j < 2; j++) { + logits_per_level[i][j] = logits_per_image_as_float[2 * index + j]; + } + for (int j = 0; j < 4; j++) { + anchors_per_level[i][j] = anchors_per_image_as_float[4 * index + j]; + } + } + + List<Detection> candidates = new LinkedList<>(); + for (int i = 0; i < num_anchors_per_level[level]; i++) { + for (int label = 0; label < numberOfClasses /* This is actually "2" */; label++) { + double score = sigmoid(logits_per_level[i][label]); + if (score > SCORE_THRESHOLD) { + Rectangle rectangle = decodeSingle( + new float[]{box_regression_per_level[i][0], box_regression_per_level[i][1], box_regression_per_level[i][2], box_regression_per_level[i][3]}, + new float[]{anchors_per_level[i][0], anchors_per_level[i][1], anchors_per_level[i][2], anchors_per_level[i][3]}, + imageWidth, + imageHeight); + + // This is an entry in "topk_idxs" in Python + candidates.add(new Detection(rectangle, label, score)); + } + } + } + + candidates.stream().sorted().limit(TOP_K_CANDIDATES).forEach(detection -> detections.add(detection)); + } + + + /** + * This is non-maximal suppression, which corresponds to + * function "batched_nms()", then "_batched_nms_vanilla()" in: + * https://github.com/pytorch/vision/blob/main/torchvision/ops/boxes.py + * + * Note that "iou_threshold" equals "self.nms_thresh" of the RetinaNet. + **/ + + final float NMS_THRESH = 0.3f; // Note that by default, "retinanet.py" uses 0.5 + + List<Detection> toKeep = new LinkedList<>(); + + for (int label = 0; label < numberOfClasses; label++) { + /** + * This corresponds to "torch.ops.torchvision.nms(boxes, + * scores, iou_threshold)", which is the native function + * "nms_kernel_impl()" implemented in C++: + * https://github.com/pytorch/vision/blob/main/torchvision/csrc/ops/cpu/nms_kernel.cpp + **/ + + List<Detection> tmp = new ArrayList<>(); + + for (Detection detection : detections) { + if (detection.getLabel() == label) { + tmp.add(detection); + } + } + + if (tmp.size() > 0) { + /** + * Performs non-maximum suppression (NMS) on the boxes according + * to their intersection-over-union (IoU). + * NMS iteratively removes lower scoring boxes which have an + * IoU greater than iou_threshold with another (higher scoring) + * box. + */ + Collections.sort(tmp); // Sort by decreasing scores + final Detection[] dets = tmp.toArray(new Detection[0]); + final int ndets = dets.length; + + boolean[] suppressed = new boolean[ndets]; + + for (int i = 0; i < ndets; i++) { + if (!suppressed[i]) { + toKeep.add(dets[i]); + + final float iarea = dets[i].getRectangle().getArea(); + + for (int j = i + 1; j < ndets; j++) { + if (!suppressed[j]) { + final float xx1 = Math.max(dets[i].getRectangle().getX1(), dets[j].getRectangle().getX1()); + final float yy1 = Math.max(dets[i].getRectangle().getY1(), dets[j].getRectangle().getY1()); + final float xx2 = Math.min(dets[i].getRectangle().getX2(), dets[j].getRectangle().getX2()); + final float yy2 = Math.min(dets[i].getRectangle().getY2(), dets[j].getRectangle().getY2()); + final float w = Math.max(0, xx2 - xx1); + final float h = Math.max(0, yy2 - yy1); + final float inter = w * h; + final float ovr = inter / (iarea + dets[j].getRectangle().getArea() - inter); + if (ovr > NMS_THRESH) { + suppressed[j] = true; + } + } + } + } + } + } + } + + List<Detection> toSerialize = new LinkedList<>(); + + for (Detection detection : toKeep) { + if (detection.getLabel() == 1 /* "1" is the "mass" label */ && + detection.getScore() >= 0.2f /* This is the "minimum_score=0.2" in "dicom_sr.py" */) { + toSerialize.add(detection); + } + } + + return toSerialize; + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/MammographyDeepLearning/src/main/resources/OrthancExplorer.js Wed Jun 12 13:58:29 2024 +0200 @@ -0,0 +1,133 @@ +/** + * SPDX-FileCopyrightText: 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + **/ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023-2024 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + **/ + + +var mammographyButton = null; + +$('#instance').live('pagecreate', function() { + mammographyButton = $('<a>') + .attr('data-role', 'button') + .attr('href', '#') + .attr('data-icon', 'search') + .attr('data-theme', 'e') + .text('Deep learning for mammography'); + + mammographyButton.insertBefore($('#instance-delete').parent().parent()); + + mammographyButton.click(function() { + if ($.mobile.pageData) { + $.ajax({ + type: 'POST', + url: '../java-mammography-apply', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify({ + 'instance' : $.mobile.pageData.uuid, + }), + cache: false, + success: function(result) { + window.location.assign('explorer.html#series?uuid=' + result.ParentSeries); + }, + error: function() { + alert('Cannot apply deep learning model'); + } + }); + } + }); +}); + +$('#instance').live('pagebeforeshow', function() { + mammographyButton.hide(); +}); + +$('#instance').live('pageshow', function() { + $.ajax({ + url: '../instances/' + $.mobile.pageData.uuid + '/tags?simplify', + dataType: 'json', + cache: false, + success: function(tags) { + if (tags.Modality == 'MG') { + mammographyButton.show(); + } + } + }); +}); + + +$('#study').live('pagebeforecreate', function() { + var b = $('<a>') + .attr('data-role', 'button') + .attr('href', '#') + .attr('data-icon', 'search') + .attr('data-theme', 'e') + .text('Stone Web Viewer (for mammography)'); + + b.insertBefore($('#study-delete').parent().parent()); + b.click(function() { + if ($.mobile.pageData) { + $.ajax({ + url: '../studies/' + $.mobile.pageData.uuid, + dataType: 'json', + cache: false, + success: function(study) { + var studyInstanceUid = study.MainDicomTags.StudyInstanceUID; + window.open('../java-mammography-viewer/index.html?study=' + studyInstanceUid); + } + }); + } + }); +}); + + +$('#series').live('pagebeforecreate', function() { + var b = $('<a>') + .attr('data-role', 'button') + .attr('href', '#') + .attr('data-icon', 'search') + .attr('data-theme', 'e') + .text('Stone Web Viewer (for mammography)'); + + b.insertBefore($('#series-delete').parent().parent()); + b.click(function() { + if ($.mobile.pageData) { + $.ajax({ + url: '../series/' + $.mobile.pageData.uuid, + dataType: 'json', + cache: false, + success: function(series) { + $.ajax({ + url: '../studies/' + series.ParentStudy, + dataType: 'json', + cache: false, + success: function(study) { + var studyInstanceUid = study.MainDicomTags.StudyInstanceUID; + var seriesInstanceUid = series.MainDicomTags.SeriesInstanceUID; + window.open('../java-mammography-viewer/index.html?study=' + studyInstanceUid + + '&series=' + seriesInstanceUid); + } + }); + } + }); + } + }); +});