diff --git a/CHANGELOG.md b/CHANGELOG.md index 3936dd715..406ddf91f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,11 @@ * Minor changes to display names of detection classifiers to match with OpenCV's names (functionality unaffected) * Fixed bug that prevented images being opened if OpenSlide native libraries could not be found * Fixed estimate of image size used when opening non-pyramidal images +* New scripting functions (e.g. setIntensityClassifications) to simplify (sub-)classifying cells according to staining intensity ([example](https://gist.github.com/petebankhead/6f73a01a67935dae2f7fa75fabe0d6ee)) +* New, non-default PathClasses are now assigned a random color (rather than black) +* Modified default color for Stroma classifications, to improve contrast * Using PROJECT_BASE_DIR in a script now fails with an appropriate error when called without a corresponding project -* Added experimental guiscript option for running short GUI-oriented scripts in the JavaFX Platform thread +* Added experimental guiscript option for running short GUI-oriented scripts in the JavaFX Platform thread ([example](https://gist.github.com/petebankhead/6f73a01a67935dae2f7fa75fabe0d6ee)) * DialogHelperFX methods can now be called from any thread (not only the Platform thread) * ImageJ macro runner supports parallel processing (experimental) * ImageJ macro runner now prompts to select all TMA cores if none are selected diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index ed5e80197..594944abe 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -255,6 +255,11 @@ public static void removeObjects(PathObject[] pathObjects, boolean keepChildren) removeObjects(Arrays.asList(pathObjects), keepChildren); } + /** + * Get a count of the total number of objects in the current hierarchy. + * + * @return + */ public static int nObjects() { PathObjectHierarchy hierarchy = getCurrentHierarchy(); if (hierarchy == null) @@ -553,11 +558,23 @@ public static boolean relabelTMAGrid(final String labelsHorizontal, final String } + /** + * Reset the PathClass for all objects of the specified type in the current hierarchy. + * + * @param hierarchy + * @param cls + */ public static void resetClassifications(final Class cls) { PathObjectHierarchy hierarchy = getCurrentHierarchy(); resetClassifications(hierarchy, cls); } + /** + * Reset the PathClass for all objects of the specified type in the specified hierarchy. + * + * @param hierarchy + * @param cls + */ public static void resetClassifications(final PathObjectHierarchy hierarchy, final Class cls) { if (hierarchy == null) return; @@ -573,18 +590,34 @@ public static void resetClassifications(final PathObjectHierarchy hierarchy, fin hierarchy.fireObjectClassificationsChangedEvent(QP.class, objects); } + /** + * Reset the PathClass for all detection objects in the current hierarchy. + */ public static void resetDetectionClassifications() { // resetClassifications(PathAnnotationObject.class); resetClassifications(PathDetectionObject.class); } - - public static boolean hasMeasurement(final PathObject pathObject, final String name) { - return pathObject != null && pathObject.getMeasurementList().containsNamedMeasurement(name); + /** + * Test whether a PathObject has a specified measurement in its measurement list. + * + * @param pathObject + * @param name + * @return + */ + public static boolean hasMeasurement(final PathObject pathObject, final String measurementName) { + return pathObject != null && pathObject.getMeasurementList().containsNamedMeasurement(measurementName); } - public static double measurement(final PathObject pathObject, final String name) { - return pathObject == null ? Double.NaN : pathObject.getMeasurementList().getMeasurementValue(name); + /** + * Extract the specified measurement from a PathObject. + * + * @param pathObject + * @param name + * @return + */ + public static double measurement(final PathObject pathObject, final String measurementName) { + return pathObject == null ? Double.NaN : pathObject.getMeasurementList().getMeasurementValue(measurementName); } @@ -679,48 +712,83 @@ public static void selectObjectsByClass(final PathObjectHierarchy hierarchy, fin selectObjects(hierarchy, p -> cls.isInstance(p)); } + /** + * Select all annotation objects in the specified hierarchy. + * @param hierarchy + */ public static void selectAnnotations(final PathObjectHierarchy hierarchy) { selectObjectsByClass(hierarchy, PathAnnotationObject.class); } + /** + * Select all TMA core objects in the specified hierarchy, excluding missing cores. + * @param hierarchy + */ public static void selectTMACores(final PathObjectHierarchy hierarchy) { selectTMACores(hierarchy, false); } + /** + * Select all TMA core objects in the specified hierarchy, optionally including missing cores. + * @param hierarchy + */ public static void selectTMACores(final PathObjectHierarchy hierarchy, final boolean includeMissing) { hierarchy.getSelectionModel().setSelectedObjects(PathObjectTools.getTMACoreObjects(hierarchy, includeMissing), null); } + /** + * Select all detection objects in the specified hierarchy. + * @param hierarchy + */ public static void selectDetections(final PathObjectHierarchy hierarchy) { selectObjectsByClass(hierarchy, PathDetectionObject.class); } + /** + * Select all cell objects in the specified hierarchy. + * @param hierarchy + */ public static void selectCells(final PathObjectHierarchy hierarchy) { selectObjectsByClass(hierarchy, PathCellObject.class); } + /** + * Select all annotation objects in the current hierarchy. + */ public static void selectAnnotations() { PathObjectHierarchy hierarchy = getCurrentHierarchy(); if (hierarchy != null) selectAnnotations(hierarchy); } + /** + * Select all TMA core objects in the current hierarchy, excluding missing cores. + */ public static void selectTMACores() { selectTMACores(false); } + /** + * Select all TMA core objects in the current hierarchy, optionally including missing cores. + */ public static void selectTMACores(final boolean includeMissing) { PathObjectHierarchy hierarchy = getCurrentHierarchy(); if (hierarchy != null) selectTMACores(hierarchy, includeMissing); } + /** + * Select all detection objects in the current hierarchy. + */ public static void selectDetections() { PathObjectHierarchy hierarchy = getCurrentHierarchy(); if (hierarchy != null) selectDetections(hierarchy); } + /** + * Select all cell objects in the current hierarchy. + */ public static void selectCells() { PathObjectHierarchy hierarchy = getCurrentHierarchy(); if (hierarchy != null) @@ -824,18 +892,30 @@ public static void selectObjectsByMeasurement(final String command) { selectObjects(hierarchy, parsePredicate(command)); } - public static void classifySelected(final String name) { + /** + * Set the classification of the selected objects in the current hierarchy. + * + * @param hierarchy + * @param pathClassName + */ + public static void classifySelected(final String pathClassName) { PathObjectHierarchy hierarchy = getCurrentHierarchy(); if (hierarchy != null) - classifySelected(hierarchy, name); + classifySelected(hierarchy, pathClassName); } - public static void classifySelected(final PathObjectHierarchy hierarchy, final String name) { - if (!PathClassFactory.pathClassExists(name)) { - logger.error("No class exists called {}", name); + /** + * Set the classification of the selected objects. + * + * @param hierarchy + * @param pathClassName + */ + public static void classifySelected(final PathObjectHierarchy hierarchy, final String pathClassName) { + if (!PathClassFactory.pathClassExists(pathClassName)) { + logger.error("No class exists called {}", pathClassName); return; } - PathClass pathClass = PathClassFactory.getPathClass(name); + PathClass pathClass = PathClassFactory.getPathClass(pathClassName); Collection selected = hierarchy.getSelectionModel().getSelectedObjects(); if (selected.isEmpty()) { logger.info("No objects selected"); @@ -845,19 +925,29 @@ public static void classifySelected(final PathObjectHierarchy hierarchy, final S pathObject.setPathClass(pathClass); } if (selected.size() == 1) - logger.info("{} object classified as {}", selected.size(), name); + logger.info("{} object classified as {}", selected.size(), pathClassName); else - logger.info("{} objects classified as {}", selected.size(), name); + logger.info("{} objects classified as {}", selected.size(), pathClassName); hierarchy.fireObjectClassificationsChangedEvent(null, selected); } + /** + * Clear the selection for the current hierarchy, so that no objects of any kind are selected. + * + * @param hierarchy + */ public static void deselectAll() { PathObjectHierarchy hierarchy = getCurrentHierarchy(); if (hierarchy != null) deselectAll(hierarchy); } + /** + * Clear the selection, so that no objects of any kind are selected. + * + * @param hierarchy + */ public static void deselectAll(final PathObjectHierarchy hierarchy) { hierarchy.getSelectionModel().clearSelection(); } @@ -944,5 +1034,163 @@ public static void removeMeasurements(final PathObjectHierarchy hierarchy, final } hierarchy.fireObjectMeasurementsChangedEvent(null, pathObjects); } + + + + + + + + + + /** + * Get a base class - which is either a valid PathClass which is *not* an intensity class, or else null. + * + * This will be null if pathObject.getPathClass() == null. + * + * Otherwise, it will be pathObject.getPathClass().getBaseClass() assuming the result isn't an intensity class - or null otherwise. + * + * @param pathObject + * @return + */ + public static PathClass getBasePathClass(final PathObject pathObject) { + PathClass baseClass = pathObject.getPathClass(); + if (baseClass != null) { + baseClass = baseClass.getBaseClass(); + // Check our base isn't an intensity class + if (PathClassFactory.isPositiveOrPositiveIntensityClass(baseClass) || PathClassFactory.isNegativeClass(baseClass)) + baseClass = null; + } + return baseClass; + } + + + /** + * Get the first ancestor class of pathObject.getPathClass() that is not an intensity class (i.e. not negative, positive, 1+, 2+ or 3+). + * + * This will return null if pathClass is null, or if no non-intensity classes are found. + * + * @param pathObject + * @return + */ + public static PathClass getNonIntensityAncestorPathClass(final PathObject pathObject) { + return PathClassFactory.getNonIntensityAncestorClass(pathObject.getPathClass()); + } + + + /** + * Assign cell classifications as positive or negative based upon a specified measurement, using up to 3 intensity bins. + * + * An IllegalArgumentException is thrown if < 1 or > 3 intensity thresholds are provided. + * + * @param pathObject The object to classify. + * @param measurementName The name of the measurement to use for thresholding. + * @param threshold1Plus The first (lowest) threshold. An object with >= threshold1Plus will be classified as positive. + * @param threshold2Plus The second (intermediate) threshold. An object with >= threshold1Plus will be classified as moderately positive. + * @param threshold3Plus The third (high) threshold. An object with >= threshold1Plus will be classified as strongly positive. + * @param singleThreshold If true, only threshold1Plus will be used. + * @return the PathClass of the object after running this method. + */ + public static PathClass setIntensityClassification(final PathObject pathObject, final String measurementName, final double... thresholds) { + if (thresholds.length == 0 || thresholds.length > 3) + throw new IllegalArgumentException("Between 1 and 3 intensity thresholds required!"); + + PathClass baseClass = getNonIntensityAncestorPathClass(pathObject); + double estimatedSpots = pathObject.getMeasurementList().getMeasurementValue(measurementName); + + boolean singleThreshold = thresholds.length == 1; + + if (estimatedSpots < thresholds[0]) { + pathObject.setPathClass(PathClassFactory.getNegative(baseClass, null)); + } else { + if (singleThreshold) + pathObject.setPathClass(PathClassFactory.getPositive(baseClass, null)); + else if (thresholds.length >= 3 && estimatedSpots >= thresholds[2]) + pathObject.setPathClass(PathClassFactory.getThreePlus(baseClass, null)); + else if (thresholds.length >= 2 && estimatedSpots >= thresholds[1]) + pathObject.setPathClass(PathClassFactory.getTwoPlus(baseClass, null)); + else if (estimatedSpots >= thresholds[0]) + pathObject.setPathClass(PathClassFactory.getOnePlus(baseClass, null)); + } + return pathObject.getPathClass(); + } + + public static void setIntensityClassifications(final Collection pathObjects, final String measurementName, final double... thresholds) { + for (PathObject pathObject : pathObjects) + setIntensityClassification(pathObject, measurementName, thresholds); + } + + /** + * Set intensity classifications for all selected (detection) objects. + * + * @param hierarchy + * @param measurementName + * @param thresholds + */ + public static void setIntensityClassificationsForSelected(final PathObjectHierarchy hierarchy, final String measurementName, final double... thresholds) { + // Get all selected detections + List pathObjects = hierarchy.getSelectionModel().getSelectedObjects() + .stream().filter(p -> p.isDetection()).collect(Collectors.toList()); + setIntensityClassifications(pathObjects, measurementName, thresholds); + hierarchy.fireObjectClassificationsChangedEvent(QP.class, pathObjects); + } + + public static void setIntensityClassifications(final PathObjectHierarchy hierarchy, final Class cls, final String measurementName, final double... thresholds) { + List pathObjects = hierarchy.getObjects(null, cls); + setIntensityClassifications(pathObjects, measurementName, thresholds); + hierarchy.fireObjectClassificationsChangedEvent(QP.class, pathObjects); + } + + public static void setIntensityClassifications(final Class cls, final String measurementName, final double... thresholds) { + setIntensityClassifications(getCurrentHierarchy(), cls, measurementName, thresholds); + } + + public static void setCellIntensityClassifications(final String measurementName, final double... thresholds) { + setCellIntensityClassifications(getCurrentHierarchy(), measurementName, thresholds); + } + + public static void setCellIntensityClassifications(final PathObjectHierarchy hierarchy, final String measurementName, final double... thresholds) { + setIntensityClassifications(hierarchy, PathCellObject.class, measurementName, thresholds); + } + + + /** + * Reset the intensity classifications for all specified objects. + * + * This means setting the classification to the result of getNonIntensityAncestorPathClass(pathObject) + * + * @param hierarchy + */ + public static void resetIntensityClassifications(final Collection pathObjects) { + for (PathObject pathObject : pathObjects) { + PathClass currentClass = pathObject.getPathClass(); + if (PathClassFactory.isPositiveOrPositiveIntensityClass(currentClass) || PathClassFactory.isNegativeClass(currentClass)) + pathObject.setPathClass(getNonIntensityAncestorPathClass(pathObject)); + } + } + + /** + * Reset the intensity classifications for all detections in the specified hierarchy. + * + * This means setting the classification to the result of getNonIntensityAncestorPathClass(pathObject) + * + * @param hierarchy + */ + public static void resetIntensityClassifications(final PathObjectHierarchy hierarchy) { + List pathObjects = hierarchy.getObjects(null, PathDetectionObject.class); + resetIntensityClassifications(pathObjects); + hierarchy.fireObjectClassificationsChangedEvent(QP.class, pathObjects); + } + + /** + * Reset the intensity classifications for all detections in the current hierarchy. + * + * This means setting the classification to the result of getNonIntensityAncestorPathClass(pathObject) + * + */ + public static void resetIntensityClassifications() { + resetIntensityClassifications(getCurrentHierarchy()); + } + } diff --git a/qupath-core/src/main/java/qupath/lib/objects/classes/PathClass.java b/qupath-core/src/main/java/qupath/lib/objects/classes/PathClass.java index 1a4e44af2..c9cf04c3e 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/classes/PathClass.java +++ b/qupath-core/src/main/java/qupath/lib/objects/classes/PathClass.java @@ -144,7 +144,7 @@ public String getName() { } static String derivedClassToString(PathClass parent, String name) { - return parent.getName() + ": " + name; + return parent == null ? name : parent.getName() + ": " + name; } @Override diff --git a/qupath-core/src/main/java/qupath/lib/objects/classes/PathClassFactory.java b/qupath-core/src/main/java/qupath/lib/objects/classes/PathClassFactory.java index 47ef4deba..a8216f673 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/classes/PathClassFactory.java +++ b/qupath-core/src/main/java/qupath/lib/objects/classes/PathClassFactory.java @@ -65,7 +65,7 @@ public enum PathClasses { TUMOR, NON_TUMOR, STROMA, IMMUNE_CELLS, NUCLEUS, CELL, DEFAULT_PATH_CLASSES = new HashMap<>(); DEFAULT_PATH_CLASSES.put(PathClasses.TUMOR, new PathClass("Tumor", ColorTools.makeRGB(200, 0, 0))); DEFAULT_PATH_CLASSES.put(PathClasses.NON_TUMOR, new PathClass("Non-tumor", ColorTools.makeRGB(140, 220, 90))); - DEFAULT_PATH_CLASSES.put(PathClasses.STROMA, new PathClass("Stroma", ColorTools.makeRGB(90, 180, 90))); + DEFAULT_PATH_CLASSES.put(PathClasses.STROMA, new PathClass("Stroma", ColorTools.makeRGB(150, 200, 150))); DEFAULT_PATH_CLASSES.put(PathClasses.IMMUNE_CELLS, new PathClass("Immune cells", ColorTools.makeRGB(160, 90, 160))); DEFAULT_PATH_CLASSES.put(PathClasses.NUCLEUS, new PathClass("Nucleus", ColorTools.makeRGB(20, 200, 20))); DEFAULT_PATH_CLASSES.put(PathClasses.CELL, new PathClass("Cell", ColorTools.makeRGB(220, 0, 0))); @@ -148,12 +148,46 @@ public static boolean isNegativeClass(final PathClass pathClass) { return pathClass != null && NEGATIVE.equals(pathClass.getName()); } + /** + * Get the first ancestor class that is not an intensity class (i.e. not negative, positive, 1+, 2+ or 3+). + * + * This will return null if pathClass is null, or if no non-intensity classes are found. + * + * @param pathClass + * @return + */ + public static PathClass getNonIntensityAncestorClass(PathClass pathClass) { + while (pathClass != null && (PathClassFactory.isPositiveOrPositiveIntensityClass(pathClass) || PathClassFactory.isNegativeClass(pathClass))) + pathClass = pathClass.getParentClass(); + return pathClass; + } + public static PathClass getPathClass(String name, Integer rgb) { if (name == null) return NULL_CLASS; PathClass pathClass = mapPathBaseClasses.get(name); if (pathClass == null) { + if (rgb == null) { + // Use default colors for intensity classes + if (name.equals(ONE_PLUS)) { + rgb = COLOR_ONE_PLUS; + } else if (name.equals(TWO_PLUS)) { + rgb = COLOR_TWO_PLUS; + } else if (name.equals(THREE_PLUS)) + rgb = COLOR_THREE_PLUS; + else if (name.equals(POSITIVE)) { + rgb = COLOR_POSITIVE; + } else if (name.equals(NEGATIVE)) { + rgb = COLOR_NEGATIVE; + } else { + // Create a random color + rgb = ColorTools.makeRGB( + (int)(Math.random()*255), + (int)(Math.random()*255), + (int)(Math.random()*255)); + } + } pathClass = new PathClass(null, name, rgb); mapPathBaseClasses.put(pathClass.toString(), pathClass); } @@ -201,11 +235,11 @@ public static PathClass getDerivedPathClass(PathClass parentClass, String name, boolean isTumor = DEFAULT_PATH_CLASSES.get(PathClasses.TUMOR) == parentClass; int parentRGB = parentClass.getColor(); if (name.equals(ONE_PLUS)) { - rgb = isTumor ? COLOR_ONE_PLUS : ColorTools.makeScaledRGB(parentRGB, 1.25); + rgb = isTumor ? COLOR_ONE_PLUS : ColorTools.makeScaledRGB(parentRGB, 0.9); } else if (name.equals(TWO_PLUS)) { - rgb = isTumor ? COLOR_TWO_PLUS : ColorTools.makeScaledRGB(parentRGB, 0.75); + rgb = isTumor ? COLOR_TWO_PLUS : ColorTools.makeScaledRGB(parentRGB, 0.6); } else if (name.equals(THREE_PLUS)) - rgb = isTumor ? COLOR_THREE_PLUS : ColorTools.makeScaledRGB(parentRGB, 0.5); + rgb = isTumor ? COLOR_THREE_PLUS : ColorTools.makeScaledRGB(parentRGB, 0.4); else if (name.equals(POSITIVE)) { rgb = isTumor ? COLOR_POSITIVE : ColorTools.makeScaledRGB(parentRGB, 0.75); } else if (name.equals(NEGATIVE)) {