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 180e9a7..d1a4dc5 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; @@ -79,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; @@ -99,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; @@ -119,6 +121,7 @@ import qupath.lib.regions.RegionRequest; import qupath.lib.roi.GeometryTools; import qupath.opencv.tools.OpenCVTools; +import javafx.scene.image.ImageView; /** @@ -131,16 +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 final StringProperty affineStringProperty; + private final StringProperty filterText = new SimpleStringProperty(); private static enum RegistrationType { AFFINE, RIGID; @@ -157,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; @@ -176,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(); @@ -207,6 +204,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); @@ -254,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: "); @@ -514,43 +512,65 @@ 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 - ListView> list = new ListView<>(); - list.getItems().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); - } - + ListSelectionView> list = new ListSelectionView<>(); + list.getSourceItems().setAll(entries); + + 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().isEmpty()) + updateImageList(list, entries, alreadySelected, tfFilter.getText()); + 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.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<>(list.getTargetItems()); Set> toRemove = new HashSet<>(alreadySelected); + + toRemove.remove(currentEntry); toRemove.removeAll(toSelect); toSelect.removeAll(alreadySelected); - + // 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) + for (ProjectImageEntry entry : toRemove) { + for (ImageData temp : images) { + if (entry == currentEntry) { imagesToRemove.add(temp); + } } - } + } images.removeAll(imagesToRemove); for (ImageData temp : imagesToRemove) { ImageServerOverlay overlay = mapOverlays.remove(temp); @@ -570,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()))) { @@ -598,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); - } @@ -633,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() + ) + ); } @@ -672,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) { @@ -724,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; } @@ -738,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)) @@ -801,17 +812,14 @@ static void autoAlign(ImageServer serverBase, ImageServer 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); + + // 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); + } }