From 3eeadefd1867784c71908176c2a0b293eaef7927 Mon Sep 17 00:00:00 2001 From: zindy Date: Fri, 5 Jan 2024 20:41:43 +0100 Subject: [PATCH 1/2] Replaced the Image selection dialog with a searchable ListSelectionView --- .../ext/align/gui/ImageAlignmentPane.java | 205 +++++++++++++++++- 1 file changed, 194 insertions(+), 11 deletions(-) diff --git a/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java b/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java index 180e9a7..b9836c5 100644 --- a/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java +++ b/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java @@ -41,6 +41,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.Iterator; import java.util.StringTokenizer; import java.util.WeakHashMap; import java.util.stream.Collectors; @@ -52,6 +53,7 @@ import org.bytedeco.javacpp.indexer.FloatIndexer; import org.bytedeco.javacpp.indexer.Indexer; import org.controlsfx.control.CheckListView; +import org.controlsfx.control.ListSelectionView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,6 +66,7 @@ import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.StringProperty; +import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.embed.swing.SwingFXUtils; @@ -119,6 +122,7 @@ import qupath.lib.regions.RegionRequest; import qupath.lib.roi.GeometryTools; import qupath.opencv.tools.OpenCVTools; +import javafx.scene.image.ImageView; /** @@ -141,6 +145,7 @@ public class ImageAlignmentPane { private DoubleProperty rotationIncrement = new SimpleDoubleProperty(1.0); private StringProperty affineStringProperty; + private StringProperty filterText = new SimpleStringProperty(); private static enum RegistrationType { AFFINE, RIGID; @@ -207,6 +212,7 @@ public ImageAlignmentPane(final QuPathGUI qupath) { this.viewer = qupath.getViewer(); this.viewer.getView().addEventFilter(MouseEvent.ANY, mouseEventHandler); + filterText.set(""); // Create left-hand pane for list CheckListView> listImages = new CheckListView<>(images); @@ -517,38 +523,97 @@ void promptToAddImages() { images.stream().map(i -> project.getEntry(i)).collect(Collectors.toSet()); if (currentEntry != null) alreadySelected.remove(currentEntry); - + + entries.removeAll(alreadySelected); + // Create a list to display, with the appropriate selections - ListView> list = new ListView<>(); - list.getItems().setAll(entries); - list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + ListSelectionView> list = new ListSelectionView<>(); + list.getSourceItems().setAll(entries); + + //list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + /* for (int i = 0; i < entries.size(); i++) { if (alreadySelected.contains(entries.get(i))) list.getSelectionModel().select(i); } + */ + list.setCellFactory(c -> new ProjectEntryListCell()); + + // Add a filter text field + TextField tfFilter = new TextField(); + tfFilter.textProperty().bindBidirectional(filterText); + filterText.addListener((v, o, n) -> updateImageList(list, entries, alreadySelected, n)); + + if (tfFilter.getText() != "") + updateImageList(list, entries, alreadySelected, tfFilter.getText()); + + //tfFilter.textProperty().addListener((v, o, n) -> updateImageList(list, entries, alreadySelected, n)); Dialog dialog = new Dialog<>(); dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); dialog.setHeaderText("Select images to include"); dialog.getDialogPane().setContent(list); + + tfFilter.setMaxWidth(Double.MAX_VALUE); + list.setSourceFooter(tfFilter); + + // Set now, so that the label will be triggered if needed + if (alreadySelected != null && !alreadySelected.isEmpty()) { + list.getSourceItems().removeAll(alreadySelected); + list.getTargetItems().addAll(alreadySelected); + } + Optional result = dialog.showAndWait(); if (result.orElse(ButtonType.CANCEL) == ButtonType.CANCEL) return; // We now need to add some & remove some (potentially) - Set> toSelect = new LinkedHashSet<>(list.getSelectionModel().getSelectedItems()); + Set> toSelect = new LinkedHashSet<>(getTargetItems(list)); Set> toRemove = new HashSet<>(alreadySelected); + + /* + logger.info("Before clean-up..."); + for (ProjectImageEntry entry : toRemove) { + logger.info("alreadySelected: "+entry.toString()); + } + for (ProjectImageEntry entry : toSelect) { + logger.info("toSelect: "+entry.toString()); + } + for (ProjectImageEntry entry : getSourceItems((list))) { + logger.info("toRemove: "+entry.toString()); + } + */ + + toRemove.remove(currentEntry); toRemove.removeAll(toSelect); toSelect.removeAll(alreadySelected); - + + /* + logger.info("After clean-up..."); + for (ProjectImageEntry entry : toSelect) { + logger.info("toSelect: "+entry.toString()); + } + for (ProjectImageEntry entry : toRemove) { + logger.info("toRemove (without source items): "+entry.toString()); + } + */ + // Rather convoluted... but remove anything that needs to go, from the list, map & overlay if (!toRemove.isEmpty()) { List> imagesToRemove = new ArrayList<>(); - for (ImageData temp : images) { - for (ProjectImageEntry entry : toRemove) { - if (entry == currentEntry) - imagesToRemove.add(temp); + ImageData imageData = null; + for (ProjectImageEntry entry : toRemove) { + try { + imageData = entry.readImageData(); + for (ImageData temp : images) { + if (temp.getServerPath().equals(imageData.getServerPath())) { + imagesToRemove.add(temp); + } + } + } catch (IOException e) { + logger.error("Unable to read ImageData for " + entry.getImageName(), e); + continue; } } images.removeAll(imagesToRemove); @@ -1018,5 +1083,123 @@ protected void updateItem(ImageData item, boolean empty) { } + + private static class ProjectEntryListCell extends ListCell> { + + private Tooltip tooltip = new Tooltip(); + private ImageView imageView = new ImageView(); + + private ProjectEntryListCell() { + super(); + imageView.setFitWidth(250); + imageView.setFitHeight(250); + imageView.setPreserveRatio(true); + } -} + @Override + protected void updateItem(ProjectImageEntry item, boolean empty) { + super.updateItem(item, empty); + if (item == null || empty) { + setText(null); + setGraphic(null); + setTooltip(null); + return; + } + setText(item.getImageName()); + + Node tooltipGraphic = null; + BufferedImage img = null; + try { + img = (BufferedImage)item.getThumbnail(); + if (img != null) { + imageView.setImage(SwingFXUtils.toFXImage(img, null)); + tooltipGraphic = imageView; + } + } catch (Exception e) { + logger.debug("Unable to read thumbnail for {} ({})" + item.getImageName(), e.getLocalizedMessage()); + } + tooltip.setText(item.getSummary()); + if (tooltipGraphic != null) + tooltip.setGraphic(tooltipGraphic); + else + tooltip.setGraphic(null); + setTooltip(tooltip); + } + } + + /** + * We should just be able to call {@link ListSelectionView#getTargetItems()}, but in ControlsFX 11 there + * is a bug that prevents this being correctly bound. + * @param + * @param listSelectionView + * @return target items + */ + public static ObservableList getTargetItems(ListSelectionView listSelectionView) { + var skin = listSelectionView.getSkin(); + if (skin == null) { + return listSelectionView.getTargetItems(); + } + + try { + logger.debug("Attempting to access target list by reflection (required for controls-fx 11.0.0)"); + var method = skin.getClass().getMethod("getTargetListView"); + @SuppressWarnings("unchecked") + var view = (ListView)method.invoke(skin); + return view.getItems(); + } catch (Exception e) { + logger.warn("Unable to access target list by reflection, sorry", e); + return listSelectionView.getTargetItems(); + } + } + + /** + * We should just be able to call {@link ListSelectionView#getSourceItems()}, but in ControlsFX 11 there + * is a bug that prevents this being correctly bound. + * @param + * @param listSelectionView + * @return source items + */ + public static ObservableList getSourceItems(ListSelectionView listSelectionView) { + var skin = listSelectionView.getSkin(); + if (skin == null) { + return listSelectionView.getSourceItems(); + } + + try { + logger.debug("Attempting to access target list by reflection (required for controls-fx 11.0.0)"); + var method = skin.getClass().getMethod("getSourceListView"); + @SuppressWarnings("unchecked") + var view = (ListView)method.invoke(skin); + return view.getItems(); + } catch (Exception e) { + logger.warn("Unable to access target list by reflection, sorry", e); + return listSelectionView.getSourceItems(); + } + } + + private static void updateImageList(final ListSelectionView> listSelectionView, + final List> availableImages, + final Set> alreadySelected, + final String filterText) { + String text = filterText.trim().toLowerCase(); + + // Get an update source items list + List> sourceItems = new ArrayList<>(availableImages); + + //var targetItems = listImages.getItems(); + //sourceItems.removeAll(targetItems); + + // Apply filter text + if (text.length() > 0 && !sourceItems.isEmpty()) { + Iterator> iter = sourceItems.iterator(); + while (iter.hasNext()) { + if (!iter.next().getImageName().toLowerCase().contains(text)) + iter.remove(); + } + } + + if (getSourceItems(listSelectionView).equals(sourceItems)) + return; + getSourceItems(listSelectionView).setAll(sourceItems); + } +} \ No newline at end of file From e7b8e01c775c2d4c6b84e9efb2d99b28e25f91e6 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 10 Dec 2024 12:29:21 +0000 Subject: [PATCH 2/2] Update for 0.6 and lint --- build.gradle | 7 +- settings.gradle | 2 +- .../ext/align/gui/ImageAlignmentPane.java | 149 ++++++------------ 3 files changed, 51 insertions(+), 107 deletions(-) diff --git a/build.gradle b/build.gradle index 64190b3..1935247 100644 --- a/build.gradle +++ b/build.gradle @@ -28,9 +28,10 @@ base { group = "io.github.qupath" } ext.qupathVersion = gradle.ext.qupathVersion -ext.qupathJavaVersion = 17 +ext.qupathJavaVersion = libs.versions.jdk.get() as Integer dependencies { + implementation libs.qupath.fxtras shadow "io.github.qupath:qupath-gui-fx:${qupathVersion}" shadow "org.slf4j:slf4j-api:1.7.30" } @@ -54,3 +55,7 @@ java { withSourcesJar() withJavadocJar() } + +javafx { + modules = ["javafx.base", "javafx.swing", "javafx.controls", "javafx.graphics"] +} diff --git a/settings.gradle b/settings.gradle index ad8f915..30de97a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,6 @@ rootProject.name = 'qupath-extension-align' -gradle.ext.qupathVersion = "0.5.0" +gradle.ext.qupathVersion = "0.6.0-SNAPSHOT" dependencyResolutionManagement { diff --git a/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java b/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java index b9836c5..d1a4dc5 100644 --- a/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java +++ b/src/main/java/qupath/ext/align/gui/ImageAlignmentPane.java @@ -82,7 +82,6 @@ import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; -import javafx.scene.control.SelectionMode; import javafx.scene.control.Slider; import javafx.scene.control.SplitPane; import javafx.scene.control.TextArea; @@ -102,9 +101,9 @@ import javafx.scene.transform.NonInvertibleTransformException; import javafx.scene.transform.TransformChangedEvent; import javafx.stage.Stage; +import qupath.fx.dialogs.Dialogs; import qupath.lib.geom.Point2; import qupath.lib.gui.QuPathGUI; -import qupath.lib.gui.dialogs.Dialogs; import qupath.lib.gui.images.stores.ImageRenderer; import qupath.lib.gui.tools.GuiTools; import qupath.lib.gui.tools.PaneTools; @@ -135,17 +134,17 @@ */ public class ImageAlignmentPane { - private static Logger logger = LoggerFactory.getLogger(ImageAlignmentPane.class); + private static final Logger logger = LoggerFactory.getLogger(ImageAlignmentPane.class); - private QuPathGUI qupath; - private QuPathViewer viewer; + private final QuPathGUI qupath; + private final QuPathViewer viewer; - private ObservableList> images = FXCollections.observableArrayList(); - private ObjectProperty> selectedImageData = new SimpleObjectProperty<>(); - private DoubleProperty rotationIncrement = new SimpleDoubleProperty(1.0); + private final ObservableList> images = FXCollections.observableArrayList(); + private final ObjectProperty> selectedImageData = new SimpleObjectProperty<>(); + private final DoubleProperty rotationIncrement = new SimpleDoubleProperty(1.0); - private StringProperty affineStringProperty; - private StringProperty filterText = new SimpleStringProperty(); + private final StringProperty affineStringProperty; + private final StringProperty filterText = new SimpleStringProperty(); private static enum RegistrationType { AFFINE, RIGID; @@ -162,7 +161,7 @@ public String toString() { } } - private ObjectProperty registrationType = new SimpleObjectProperty<>(RegistrationType.AFFINE); + private final ObjectProperty registrationType = new SimpleObjectProperty<>(RegistrationType.AFFINE); private static enum AlignmentMethod { INTENSITY, AREA_ANNOTATIONS, POINT_ANNOTATIONS; @@ -181,22 +180,15 @@ public String toString() { } } - private ObjectProperty alignmentMethod = new SimpleObjectProperty<>(AlignmentMethod.INTENSITY); + private final ObjectProperty alignmentMethod = new SimpleObjectProperty<>(AlignmentMethod.INTENSITY); - private Map, ImageServerOverlay> mapOverlays = new WeakHashMap<>(); - private EventHandler transformEventHandler = new EventHandler() { - @Override - public void handle(TransformChangedEvent event) { - affineTransformUpdated(); - } - }; + private final Map, ImageServerOverlay> mapOverlays = new WeakHashMap<>(); + private final EventHandler transformEventHandler = event -> affineTransformUpdated(); - private RefineTransformMouseHandler mouseEventHandler = new RefineTransformMouseHandler(); + private final RefineTransformMouseHandler mouseEventHandler = new RefineTransformMouseHandler(); - private ObjectBinding selectedOverlay = Bindings.createObjectBinding( - () -> { - return mapOverlays.get(selectedImageData.get()); - }, + private final ObjectBinding selectedOverlay = Bindings.createObjectBinding( + () -> mapOverlays.get(selectedImageData.get()), selectedImageData); private BooleanBinding noOverlay = selectedOverlay.isNull(); @@ -260,7 +252,7 @@ public ImageAlignmentPane(final QuPathGUI qupath) { if (!n.isEmpty()) { try { rotationIncrement.set(Double.parseDouble(n)); - } catch (Exception e) {} + } catch (Exception ignored) {} } }); Label labelRotationIncrement = new Label("Rotation increment: "); @@ -520,23 +512,18 @@ void promptToAddImages() { // Find the entries currently selected Set> alreadySelected = - images.stream().map(i -> project.getEntry(i)).collect(Collectors.toSet()); + images.stream() + .map(project::getEntry) + .collect(Collectors.toSet()); if (currentEntry != null) alreadySelected.remove(currentEntry); entries.removeAll(alreadySelected); - + // Create a list to display, with the appropriate selections ListSelectionView> list = new ListSelectionView<>(); list.getSourceItems().setAll(entries); - //list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); - /* - for (int i = 0; i < entries.size(); i++) { - if (alreadySelected.contains(entries.get(i))) - list.getSelectionModel().select(i); - } - */ list.setCellFactory(c -> new ProjectEntryListCell()); // Add a filter text field @@ -544,11 +531,9 @@ void promptToAddImages() { tfFilter.textProperty().bindBidirectional(filterText); filterText.addListener((v, o, n) -> updateImageList(list, entries, alreadySelected, n)); - if (tfFilter.getText() != "") + if (!tfFilter.getText().isEmpty()) updateImageList(list, entries, alreadySelected, tfFilter.getText()); - //tfFilter.textProperty().addListener((v, o, n) -> updateImageList(list, entries, alreadySelected, n)); - Dialog dialog = new Dialog<>(); dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); dialog.setHeaderText("Select images to include"); @@ -558,7 +543,7 @@ void promptToAddImages() { list.setSourceFooter(tfFilter); // Set now, so that the label will be triggered if needed - if (alreadySelected != null && !alreadySelected.isEmpty()) { + if (!alreadySelected.isEmpty()) { list.getSourceItems().removeAll(alreadySelected); list.getTargetItems().addAll(alreadySelected); } @@ -569,53 +554,23 @@ void promptToAddImages() { return; // We now need to add some & remove some (potentially) - Set> toSelect = new LinkedHashSet<>(getTargetItems(list)); + Set> toSelect = new LinkedHashSet<>(list.getTargetItems()); Set> toRemove = new HashSet<>(alreadySelected); - /* - logger.info("Before clean-up..."); - for (ProjectImageEntry entry : toRemove) { - logger.info("alreadySelected: "+entry.toString()); - } - for (ProjectImageEntry entry : toSelect) { - logger.info("toSelect: "+entry.toString()); - } - for (ProjectImageEntry entry : getSourceItems((list))) { - logger.info("toRemove: "+entry.toString()); - } - */ - toRemove.remove(currentEntry); toRemove.removeAll(toSelect); toSelect.removeAll(alreadySelected); - /* - logger.info("After clean-up..."); - for (ProjectImageEntry entry : toSelect) { - logger.info("toSelect: "+entry.toString()); - } - for (ProjectImageEntry entry : toRemove) { - logger.info("toRemove (without source items): "+entry.toString()); - } - */ - // Rather convoluted... but remove anything that needs to go, from the list, map & overlay if (!toRemove.isEmpty()) { List> imagesToRemove = new ArrayList<>(); - ImageData imageData = null; for (ProjectImageEntry entry : toRemove) { - try { - imageData = entry.readImageData(); - for (ImageData temp : images) { - if (temp.getServerPath().equals(imageData.getServerPath())) { - imagesToRemove.add(temp); - } + for (ImageData temp : images) { + if (entry == currentEntry) { + imagesToRemove.add(temp); } - } catch (IOException e) { - logger.error("Unable to read ImageData for " + entry.getImageName(), e); - continue; } - } + } images.removeAll(imagesToRemove); for (ImageData temp : imagesToRemove) { ImageServerOverlay overlay = mapOverlays.remove(temp); @@ -635,7 +590,6 @@ void promptToAddImages() { // Read annotations from any data file try { // Try to get data from an open viewer first, if possible - for (var viewer : qupath.getAllViewers()) { var tempData = viewer.getImageData(); if (tempData != null && temp.equals(project.getEntry(viewer.getImageData()))) { @@ -663,16 +617,13 @@ void promptToAddImages() { continue; } ImageServerOverlay overlay = new ImageServerOverlay(viewer, imageData.getServer()); - //@phaub Support of viewer display settings overlay.setRenderer(renderer); overlay.getAffine().addEventHandler(TransformChangedEvent.ANY, transformEventHandler); mapOverlays.put(imageData, overlay); -// viewer.getCustomOverlayLayers().add(overlay); imagesToAdd.add(imageData); } images.addAll(0, imagesToAdd); - } @@ -698,15 +649,12 @@ private void affineTransformUpdated() { Affine affine = overlay.getAffine(); affineStringProperty.set( String.format( - "%.4f, \t %.4f,\t %.4f,\n" + - "%.4f,\t %.4f,\t %.4f", -// String.format("Transform: [\n" + -// " %.3f, %.3f, %.3f,\n" + -// " %.3f, %.3f, %.3f\n" + -// "]", - affine.getMxx(), affine.getMxy(), affine.getTx(), - affine.getMyx(), affine.getMyy(), affine.getTy()) - ); + "%.4f, \t %.4f,\t %.4f,\n" + + "%.4f,\t %.4f,\t %.4f", + affine.getMxx(), affine.getMxy(), affine.getTx(), + affine.getMyx(), affine.getMyy(), affine.getTy() + ) + ); } @@ -737,14 +685,14 @@ static BufferedImage ensureGrayScale(BufferedImage img) { /** * Auto-align the selected image overlay with the base image in the viewer. * - * @param requestedPixelSizeMicrons + * @param requestedPixelSizeMicrons The requested pixel size in microns. * @throws IOException */ void autoAlign(double requestedPixelSizeMicrons) throws IOException { ImageData imageDataBase = viewer.getImageData(); ImageData imageDataSelected = selectedImageData.get(); if (imageDataBase == null) { - Dialogs.showNoImageError("Auto-alignment"); + Dialogs.showErrorMessage("Auto-alignment", "No image is available!"); return; } if (imageDataSelected == null) { @@ -789,12 +737,10 @@ void autoAlign(double requestedPixelSizeMicrons) throws IOException { } Mat matBase = pointsToMat(pointsBase); Mat matSelected = pointsToMat(pointsSelected); - + + // @deprecated Use cv::estimateAffine2D, cv::estimateAffinePartial2D instead. If you are using this function + // with images, extract points using cv::calcOpticalFlowPyrLK and then use the estimation functions. transform = opencv_video.estimateRigidTransform(matBase, matSelected, registrationType.get() == RegistrationType.AFFINE); -// if (registrationType.get() == RegistrationType.AFFINE) -// transform = opencv_calib3d.estimateAffine2D(matBase, matSelected); -// else -// transform = opencv_calib3d.estimateAffinePartial2D(matBase, matSelected); matToAffine(transform, affine, 1.0); return; } @@ -803,7 +749,7 @@ void autoAlign(double requestedPixelSizeMicrons) throws IOException { logger.debug("Image alignment using area annotations"); Map labels = new LinkedHashMap<>(); int label = 1; - labels.put(PathClassFactory.getPathClassUnclassified(), label++); + labels.put(PathClass.NULL_CLASS, label++); for (var annotation : imageDataBase.getHierarchy().getAnnotationObjects()) { var pathClass = annotation.getPathClass(); if (pathClass != null && !labels.containsKey(pathClass)) @@ -866,17 +812,14 @@ static void autoAlign(ImageServer serverBase, ImageServer> sourceItems = new ArrayList<>(availableImages); - //var targetItems = listImages.getItems(); - //sourceItems.removeAll(targetItems); - // Apply filter text if (text.length() > 0 && !sourceItems.isEmpty()) { Iterator> iter = sourceItems.iterator(); @@ -1202,4 +1141,4 @@ private static void updateImageList(final ListSelectionView