From 97f89d11b99300472a8178d12fe9bbdcb0898350 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 28 Aug 2024 17:18:00 +0100 Subject: [PATCH 01/15] Implementation of downloading alongside local models --- build.gradle | 1 + .../qupath/ext/instanseg/core/InstanSeg.java | 24 +- .../ext/instanseg/core/InstanSegModel.java | 153 ++++++++--- .../ext/instanseg/ui/InstanSegController.java | 259 ++++++++++++++++-- .../ext/instanseg/ui/MessageTextHelper.java | 33 ++- .../java/qupath/ext/instanseg/ui/Watcher.java | 10 +- .../ext/instanseg/ui/strings.properties | 2 + 7 files changed, 396 insertions(+), 86 deletions(-) diff --git a/build.gradle b/build.gradle index c5d3835..26de2cf 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,7 @@ dependencies { shadow libs.qupath.fxtras shadow libs.bioimageio.spec shadow libs.deepJavaLibrary + shadow libs.commonmark implementation 'io.github.qupath:qupath-extension-djl:0.3.0' diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSeg.java b/src/main/java/qupath/ext/instanseg/core/InstanSeg.java index 0947d6a..51dbdd5 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSeg.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSeg.java @@ -302,14 +302,14 @@ public Builder modelPath(String path) throws IOException { return modelPath(Path.of(path)); } - /** - * Set the specific model to be used - * @param name The name of a built-in model - * @return A modified builder - */ - public Builder modelName(String name) { - return model(InstanSegModel.fromName(name)); - } + // /** + // * Set the specific model to be used + // * @param name The name of a built-in model + // * @return A modified builder + // */ + // public Builder modelName(String name) { + // return model(InstanSegModel.fromName(name)); + // } /** * Set the device to be used @@ -410,8 +410,14 @@ public InstanSeg build() { * @param detections The objects to measure. */ public void makeMeasurements(ImageData imageData, Collection detections) { + Double pxSize = null; + try { + pxSize = model.getPixelSizeX(); + } catch (IOException e) { + throw new RuntimeException("Unable to fetch model pixel size", e); + } DetectionMeasurer.builder() - .pixelSize(model.getPixelSizeX()) + .pixelSize(pxSize) .build().makeMeasurements(imageData, detections); } diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index 2f15328..0fa93cf 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -10,8 +10,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qupath.bioimageio.spec.BioimageIoSpec; + import qupath.lib.experimental.pixels.OpenCVProcessor; -import qupath.lib.gui.UserDirectoryManager; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ColorTransforms; import qupath.lib.objects.PathObject; @@ -21,8 +21,16 @@ import qupath.opencv.ops.ImageOps; import java.awt.image.BufferedImage; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.URL; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -33,12 +41,16 @@ import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; public class InstanSegModel { private static final Logger logger = LoggerFactory.getLogger(InstanSegModel.class); + private Path localModelPath; + private URL modelURL = null; + private boolean downloaded = false; private Path path = null; - private URL modelURL = null; private BioimageIoSpec.BioimageIoModel model = null; private final String name; private int nFailed = 0; @@ -47,11 +59,13 @@ private InstanSegModel(BioimageIoSpec.BioimageIoModel bioimageIoModel) { this.model = bioimageIoModel; this.path = Paths.get(model.getBaseURI()); this.name = model.getName(); + this.downloaded = true; } - private InstanSegModel(URL modelURL, String name) { + private InstanSegModel(String name, URL modelURL, Path localModelPath) { this.modelURL = modelURL; this.name = name; + this.localModelPath = localModelPath; } /** @@ -64,21 +78,16 @@ public static InstanSegModel fromPath(Path path) throws IOException { return new InstanSegModel(BioimageIoSpec.parseModel(path.toFile())); } - /** - * Request an InstanSeg model from the set of available models - * @param name The model name - * @return The specified model. - */ - public static InstanSegModel fromName(String name) { - // todo: instantiate built-in models somehow - throw new UnsupportedOperationException("Fetching models by name is not yet implemented!"); + public static InstanSegModel fromURL(String name, URL browserDownloadUrl, Path localModelPath) { + // todo: this constructor should initialise a + return new InstanSegModel(name, browserDownloadUrl, localModelPath); } /** * Get the pixel size in the X dimension. * @return the pixel size in the X dimension. */ - public Double getPixelSizeX() { + public Double getPixelSizeX() throws IOException { return getPixelSize().get("x"); } @@ -86,7 +95,7 @@ public Double getPixelSizeX() { * Get the pixel size in the Y dimension. * @return the pixel size in the Y dimension. */ - public Double getPixelSizeY() { + public Double getPixelSizeY() throws IOException { return getPixelSize().get("y"); } @@ -94,9 +103,9 @@ public Double getPixelSizeY() { * Get the path where the model is stored on disk. * @return A path on disk, or an exception if it can't be found. */ - public Path getPath() { + public Path getPath() throws IOException { if (path == null) { - fetchModel(); + download(); } return path; } @@ -133,7 +142,7 @@ public int nFailed() { * Get the model name * @return A string */ - String getName() { + public String getName() { return name; } @@ -141,15 +150,85 @@ String getName() { * Retrieve the BioImage model spec. * @return The BioImageIO model spec for this InstanSeg model. */ - BioimageIoSpec.BioimageIoModel getModel() { + BioimageIoSpec.BioimageIoModel getModel() throws IOException { if (model == null) { - fetchModel(); + download(); } return model; } - private Map getPixelSize() { - // todo: this code is horrendous + public void download() throws IOException { + if (downloaded) return; + var zipFile = downloadZip( + this.modelURL, + localModelPath, + name); + this.path = unzip(zipFile); + this.model = BioimageIoSpec.parseModel(path.toFile()); + this.downloaded = true; + } + + private static Path downloadZip(URL url, Path localDirectory, String filename) { + // "https://github.com/instanseg/instanseg/releases/download/instanseg_models_v1/fluorescence_nuclei_and_cells.zip" + var zipFile = localDirectory.resolve(Path.of(filename + ".zip")); + if (!Files.exists(zipFile)) { + try (InputStream stream = url.openStream()) { + try (ReadableByteChannel readableByteChannel = Channels.newChannel(stream)) { + try (FileOutputStream fos = new FileOutputStream(zipFile.toFile())) { + fos.getChannel().transferFrom(readableByteChannel, 0, Long.MAX_VALUE); + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return zipFile; + } + + private static Path unzip(Path zipFile) { + var outdir = zipFile.resolveSibling(zipFile.getFileName().toString().replace(".zip", "")); + if (!Files.exists(outdir)) { + try { + unzip(zipFile, zipFile.getParent()); + // Files.delete(zipFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return outdir; + } + + private static void unzip(Path zipFile, Path destination) throws IOException { + if (!Files.exists(destination)) { + Files.createDirectory(destination); + } + ZipInputStream zipIn = new ZipInputStream(new BufferedInputStream(new FileInputStream(zipFile.toFile()))); + ZipEntry entry = zipIn.getNextEntry(); + while (entry != null) { + Path filePath = destination.resolve(entry.getName()); + if (entry.isDirectory()) { + Files.createDirectory(filePath); + } else { + extractFile(zipIn, filePath); + } + zipIn.closeEntry(); + entry = zipIn.getNextEntry(); + } + zipIn.close(); + } + + private static void extractFile(ZipInputStream zipIn, Path filePath) throws IOException { + BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(filePath.toFile())); + byte[] bytesIn = new byte[4096]; + int read = 0; + while ((read = zipIn.read(bytesIn)) != -1) { + bos.write(bytesIn, 0, read); + } + bos.close(); + } + + + private Map getPixelSize() throws IOException { var map = new HashMap(); var config = (LinkedTreeMap)getModel().getConfig().get("qupath"); var axes = (ArrayList)config.get("axes"); @@ -158,7 +237,7 @@ private Map getPixelSize() { return map; } - public int getNumChannels() { + public int getNumChannels() throws IOException { assert getModel().getInputs().getFirst().getAxes().equals("bcyx"); int numChannels = getModel().getInputs().getFirst().getShape().getShapeMin()[1]; if (getModel().getInputs().getFirst().getShape().getShapeStep()[1] == 1) { @@ -167,23 +246,6 @@ public int getNumChannels() { return numChannels; } - private void fetchModel() { - if (modelURL == null) { - throw new NullPointerException("Model URL should not be null for a local model!"); - } - downloadAndUnzip(modelURL, getUserDir().resolve("instanseg")); - } - - private static void downloadAndUnzip(URL url, Path localDirectory) { - // todo: implement - } - - private static Path getUserDir() { - Path userPath = UserDirectoryManager.getInstance().getUserPath(); - Path cachePath = Paths.get(System.getProperty("user.dir"), ".cache", "QuPath"); - return userPath == null || userPath.toString().isEmpty() ? cachePath : userPath; - } - void runInstanSeg( ImageData imageData, Collection pathObjects, @@ -199,7 +261,11 @@ void runInstanSeg( nFailed = 0; Path modelPath; - modelPath = getPath().resolve("instanseg.pt"); + try { + modelPath = getPath().resolve("instanseg.pt"); + } catch (IOException e) { + throw new RuntimeException(e); + } int nPredictors = 1; // todo: change me? @@ -268,4 +334,13 @@ private static void printResourceCount(String title, BaseNDManager manager) { manager.debugDump(2); } + public boolean isDownloaded() { + return downloaded; + } + + public String getREADME() throws IOException { + var file = path.resolve(name + "_README.md"); + return Files.readString(file, StandardCharsets.UTF_8); + + } } diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 25ea24e..a3f227c 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -1,8 +1,11 @@ package qupath.ext.instanseg.ui; +import com.google.gson.Gson; import javafx.application.Platform; import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; @@ -23,7 +26,10 @@ import javafx.scene.control.ToggleButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; +import javafx.scene.web.WebView; +import org.commonmark.renderer.html.HtmlRenderer; import org.controlsfx.control.CheckComboBox; +import org.controlsfx.control.PopOver; import org.controlsfx.control.SearchableComboBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +44,7 @@ import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.TaskRunnerFX; import qupath.lib.gui.tools.GuiTools; +import qupath.lib.gui.tools.WebViews; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServer; import qupath.lib.objects.PathObject; @@ -46,10 +53,17 @@ import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -76,6 +90,8 @@ public class InstanSegController extends BorderPane { @FXML private Button runButton; @FXML + private Button downloadButton; + @FXML private Label labelMessage; @FXML private ChoiceBox deviceChoices; @@ -91,12 +107,15 @@ public class InstanSegController extends BorderPane { private CheckBox nucleiOnlyCheckBox; @FXML private CheckBox makeMeasurementsCheckBox; + @FXML + private Button infoButton; private final ExecutorService pool = Executors.newSingleThreadExecutor(ThreadTools.createThreadFactory("instanseg", true)); private final QuPathGUI qupath; private ObjectProperty> pendingTask = new SimpleObjectProperty<>(); private MessageTextHelper messageTextHelper; + private final BooleanProperty needsUpdating = new SimpleBooleanProperty(); private final Watcher watcher = new Watcher(modelChoiceBox); private ExecutorService executor; @@ -180,9 +199,18 @@ private void updateChannelPicker(ImageData imageData) { if (imageData.isBrightfield()) { comboChannels.getCheckModel().checkIndices(IntStream.range(0, 3).toArray()); var model = modelChoiceBox.getSelectionModel().selectedItemProperty().get(); - if (model != null && model.getNumChannels() != Integer.MAX_VALUE) { - comboChannels.getCheckModel().clearChecks(); - comboChannels.getCheckModel().checkIndices(0, 1, 2); + if (model != null && model.isDownloaded()) { + int numChannels; + try { + numChannels = model.getNumChannels(); + } catch (IOException e) { + // shouldn't happen if downloaded anyway + throw new RuntimeException(e); + } + if (numChannels != Integer.MAX_VALUE) { + comboChannels.getCheckModel().clearChecks(); + comboChannels.getCheckModel().checkIndices(0, 1, 2); + } } } else { comboChannels.getCheckModel().checkIndices(IntStream.range(0, imageData.getServer().nChannels()).toArray()); @@ -286,7 +314,6 @@ private void configureRunning() { runButton.disableProperty().bind( qupath.imageDataProperty().isNull() .or(pendingTask.isNotNull()) - .or(modelChoiceBox.getSelectionModel().selectedItemProperty().isNull()) .or(messageTextHelper.hasWarning()) .or(deviceChoices.getSelectionModel().selectedItemProperty().isNull()) .or(Bindings.createBooleanBinding(() -> { @@ -294,10 +321,11 @@ private void configureRunning() { if (model == null) { return true; } + if (!model.isDownloaded()) return false; // to enable "download and run" int numSelected = comboChannels.getCheckModel().getCheckedIndices().size(); int numAllowed = model.getNumChannels(); return !(numSelected == numAllowed || numAllowed == Integer.MAX_VALUE); - }, modelChoiceBox.getSelectionModel().selectedItemProperty())) + }, modelChoiceBox.getSelectionModel().selectedItemProperty(), needsUpdating)) ); pendingTask.addListener((observable, oldValue, newValue) -> { if (newValue != null) { @@ -307,23 +335,90 @@ private void configureRunning() { } private void configureModelChoices() { - addRemoteModels(modelChoiceBox); tfModelDirectory.textProperty().bindBidirectional(InstanSegPreferences.modelDirectoryProperty()); handleModelDirectory(tfModelDirectory.getText()); - tfModelDirectory.textProperty().addListener((v, o, n) -> handleModelDirectory(n)); - // for brightfield models, we want to disable the picker and set it to use RGB only + addRemoteModels(modelChoiceBox); + tfModelDirectory.textProperty().addListener((v, o, n) -> { + watcher.unregister(Path.of(o)); + handleModelDirectory(n); + addRemoteModels(modelChoiceBox); + }); modelChoiceBox.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> { - if (qupath.getImageData().isBrightfield() && n != null && n.getNumChannels() != Integer.MAX_VALUE) { - comboChannels.getCheckModel().clearChecks(); - comboChannels.getCheckModel().checkIndices(0, 1, 2); + if (n == null) return; + downloadButton.setDisable(n.isDownloaded()); + infoButton.setDisable(!n.isDownloaded()); + if (!n.isDownloaded() || qupath.getImageData() == null) return; + try { + if (qupath.getImageData().isBrightfield() && n.getNumChannels() != Integer.MAX_VALUE) { + comboChannels.getCheckModel().clearChecks(); + comboChannels.getCheckModel().checkIndices(0, 1, 2); + } + } catch (IOException e) { + // shouldn't happen if model is downloaded... + throw new RuntimeException(e); } }); + downloadButton.setOnAction(e -> { + try { + var model = modelChoiceBox.getSelectionModel().getSelectedItem(); + model.download(); + Dialogs.showInfoNotification(resources.getString("title"), + String.format(resources.getString("ui.popup.available"), model.getName())); + infoButton.setDisable(false); + downloadButton.setDisable(true); + needsUpdating.set(!needsUpdating.get()); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }); + WebView webView = WebViews.create(true); + PopOver infoPopover = new PopOver(webView); + infoButton.setOnAction(e -> { + parseMarkdown(modelChoiceBox.getSelectionModel().getSelectedItem(), webView, infoButton, infoPopover); + }); + } + + + private static void parseMarkdown(InstanSegModel model, WebView webView, Button infoButton, PopOver infoPopover) { + String body = null; + try { + body = model.getREADME(); + } catch (IOException e) { + throw new RuntimeException(e); + } + // Parse the initial markdown only, to extract any YAML front matter + var parser = org.commonmark.parser.Parser.builder().build(); + var doc = parser.parse(body); + + // If the markdown doesn't start with a title, pre-pending the model title & description (if available) + if (!body.startsWith("#")) { + var sb = new StringBuilder(); + sb.append("## ").append(model.getName()).append("\n\n"); + sb.append("----\n\n"); + doc.prependChild(parser.parse(sb.toString())); + } + webView.getEngine().loadContent(HtmlRenderer.builder().build().render(doc)); + infoPopover.show(infoButton); } private static void addRemoteModels(ComboBox comboBox) { - // todo: list models from eg a JSON file + var releases = getReleases(); + if (releases.isEmpty()) { + logger.info("No releases found"); + return; + } + var release = releases.getFirst(); + var assets = getAssets(release); + assets.forEach(asset -> { + comboBox.getItems().add( + InstanSegModel.fromURL( + asset.name.replace(".zip", ""), + asset.browser_download_url, + Path.of(InstanSegPreferences.modelDirectoryProperty().get()))); + }); } + private void configureTileSizes() { tileSizeChoiceBox.getItems().addAll(128, 256, 512, 1024, 1536, 2048, 3072, 4096); tileSizeChoiceBox.getSelectionModel().select(Integer.valueOf(256)); @@ -349,8 +444,9 @@ private void handleModelDirectory(String n) { var path = Path.of(n); if (Files.exists(path) && Files.isDirectory(path)) { try { - watcher.register(path); // todo: unregister - addModelsFromPath(n, modelChoiceBox); + var localPath = path.resolve("local"); + watcher.register(localPath); // todo: unregister + addModelsFromPath(localPath, modelChoiceBox); } catch (IOException e) { logger.error("Unable to watch directory", e); } @@ -378,7 +474,7 @@ private void addDeviceChoices() { } private void configureMessageLabel() { - messageTextHelper = new MessageTextHelper(modelChoiceBox, deviceChoices, comboChannels); + messageTextHelper = new MessageTextHelper(modelChoiceBox, deviceChoices, comboChannels, needsUpdating); labelMessage.textProperty().bind(messageTextHelper.messageLabelText()); if (messageTextHelper.hasWarning().get()) { labelMessage.getStyleClass().setAll("warning-message"); @@ -393,12 +489,10 @@ private void configureMessageLabel() { }); } - static void addModelsFromPath(String dir, ComboBox box) { - if (dir == null || dir.isEmpty()) return; + static void addModelsFromPath(Path path, ComboBox box) { + if (path == null || !Files.exists(path) || !Files.isDirectory(path)) return; // See https://github.com/controlsfx/controlsfx/issues/1320 box.setItems(FXCollections.observableArrayList()); - var path = Path.of(dir); - if (!Files.exists(path)) return; try (var ps = Files.list(path)) { for (var file: ps.toList()) { if (InstanSegModel.isValidModel(file)) { @@ -423,16 +517,41 @@ private void runInstanSeg() { return; } } - - var model = modelChoiceBox.getSelectionModel().getSelectedItem(); ImageServer server = qupath.getImageData().getServer(); - // todo: how to record this in workflow? List selectedChannels = comboChannels .getCheckModel().getCheckedItems() .stream() .filter(Objects::nonNull) .toList(); + + var model = modelChoiceBox.getSelectionModel().getSelectedItem(); + if (!model.isDownloaded()) { + if (!Dialogs.showYesNoDialog(resources.getString("title"), resources.getString("ui.model-popup"))) return; + Dialogs.showInfoNotification(resources.getString("title"), String.format(resources.getString("ui.popup.fetching"), model.getName())); + try { + model.download(); + } catch (IOException e) { + Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.downloading")); + return; + } + } + + try { + int imageChannels = selectedChannels.size(); + int modelChannels = model.getNumChannels(); + if (modelChannels != Integer.MAX_VALUE || model.getNumChannels() != imageChannels) { + Dialogs.showErrorNotification(resources.getString("title"), String.format( + resources.getString("ui.error.num-channels-dont-match"), + modelChannels, imageChannels)); + return; + } + } catch (IOException e) { + Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.fetching")); + return; + } + + var task = new InstanSegTask(server, model, selectedChannels); pendingTask.set(task); // Reset the pending task when it completes (either successfully or not) @@ -469,6 +588,14 @@ protected Void call() { var imageData = qupath.getImageData(); var selectedObjects = imageData.getHierarchy().getSelectionModel().getSelectedObjects(); + double pixelSize; + Path path; + try { + pixelSize = model.getPixelSizeX(); + path = model.getPath(); + } catch (IOException e) { + throw new RuntimeException("Unable to fetch model pixel size", e); + } var instanSeg = InstanSeg.builder() .model(model) .imageData(imageData) @@ -476,7 +603,7 @@ protected Void call() { .numOutputChannels(nucleiOnlyCheckBox.isSelected() ? 1 : 2) .channels(channels.stream().map(ChannelSelectItem::getTransform).toList()) .tileDims(InstanSegPreferences.tileSizeProperty().get()) - .downsample(model.getPixelSizeX() / (double) server.getPixelCalibration().getAveragedPixelSize()) + .downsample(pixelSize / (double) server.getPixelCalibration().getAveragedPixelSize()) .taskRunner(taskRunner) .build(); instanSeg.detectObjects(selectedObjects); @@ -498,12 +625,12 @@ protected Void call() { .build(); instanSeg.detectObjects(instanSegObjects); """, - model.getPath(), + path, deviceChoices.getSelectionModel().getSelectedItem(), nucleiOnlyCheckBox.isSelected() ? 1 : 2, ChannelSelectItem.toConstructorString(channels), InstanSegPreferences.tileSizeProperty().get(), - model.getPixelSizeX() / (double) server.getPixelCalibration().getAveragedPixelSize(), + pixelSize / (double) server.getPixelCalibration().getAveragedPixelSize(), InstanSegPreferences.numThreadsProperty().getValue() ); if (makeMeasurementsCheckBox.isSelected()) { @@ -549,7 +676,6 @@ private void selectAllTMACores() { hierarchy.getSelectionModel().setSelectedObjects(hierarchy.getTMAGrid().getTMACoreList(), null); } - private void promptToUpdateDirectory(StringProperty dirPath) { var modelDirPath = dirPath.get(); var dir = modelDirPath == null || modelDirPath.isEmpty() ? null : new File(modelDirPath); @@ -568,4 +694,85 @@ else if (!dir.exists()) dirPath.set(newDir.getAbsolutePath()); } + + private static class GitHubRelease { + String tag_name; + String name; + Date published_at; + GitHubAsset[] assets; + String body; + + String getName() { + return name; + } + String getBody() { + return body; + } + Date getDate() { + return published_at; + } + String getTag() { + return tag_name; + } + + @Override + public String toString() { + return name + " with assets:" + Arrays.toString(assets); + } + } + + private static class GitHubAsset { + String name; + String content_type; + URL browser_download_url; + @Override + public String toString() { + return name; + } + + String getType() { + return content_type; + } + + URL getUrl() { + return browser_download_url; + } + + public String getName() { + return name; + } + } + + private static List getReleases() { + String uString = "https://api.github.com/repos/instanseg/InstanSeg/releases"; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uString)) + .GET() + .build(); + String response; + try (HttpClient client = HttpClient.newHttpClient()) { + response = client.send(request, HttpResponse.BodyHandlers.ofString()).body(); + } catch (IOException | InterruptedException e) { + logger.error("Unable to fetch GitHub release information", e); + return List.of(); + } + Gson gson = new Gson(); + var releases = gson.fromJson(response, GitHubRelease[].class); + if (!(releases.length > 0)) { + return List.of(); + } + return List.of(releases); + } + + private static List getAssets(GitHubRelease release) { + var assets = Arrays.stream(release.assets) + .filter(a -> a.getType().equals("application/zip")) + .toList(); + if (assets.isEmpty()) { + logger.info("No valid assets identified for {}", release.name); + } else if (assets.size() > 1) { + logger.info("More than one matching model: {}", release.name); + } + return assets; + } } diff --git a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java index 4056026..0123eca 100644 --- a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java +++ b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java @@ -5,6 +5,7 @@ import javafx.beans.binding.BooleanBinding; import javafx.beans.binding.ObjectBinding; import javafx.beans.binding.StringBinding; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleIntegerProperty; @@ -21,6 +22,7 @@ import qupath.lib.objects.hierarchy.events.PathObjectSelectionListener; import java.awt.image.BufferedImage; +import java.io.IOException; import java.util.Collection; import java.util.ResourceBundle; @@ -34,6 +36,7 @@ class MessageTextHelper { private final SearchableComboBox modelChoiceBox; private final ChoiceBox deviceChoiceBox; private final CheckComboBox comboChannels; + private final BooleanProperty needsUpdating; /** * Text to display a warning (because inference can't be run) @@ -55,10 +58,11 @@ class MessageTextHelper { */ private BooleanBinding hasWarning; - MessageTextHelper(SearchableComboBox modelChoiceBox, ChoiceBox deviceChoiceBox, CheckComboBox comboChannels) { + MessageTextHelper(SearchableComboBox modelChoiceBox, ChoiceBox deviceChoiceBox, CheckComboBox comboChannels, BooleanProperty needsUpdating) { this.modelChoiceBox = modelChoiceBox; this.deviceChoiceBox = deviceChoiceBox; this.comboChannels = comboChannels; + this.needsUpdating = needsUpdating; this.selectedObjectCounter = new SelectedObjectCounter(qupath.imageDataProperty()); configureMessageTextBindings(); } @@ -110,7 +114,8 @@ private StringBinding createWarningTextBinding() { comboChannels.getCheckModel().getCheckedItems(), deviceChoiceBox.getSelectionModel().selectedItemProperty(), selectedObjectCounter.numSelectedAnnotations, - selectedObjectCounter.numSelectedTMACores); + selectedObjectCounter.numSelectedTMACores, + needsUpdating); } private String getWarningText() { @@ -123,14 +128,22 @@ private String getWarningText() { return resources.getString("ui.error.no-selection"); if (deviceChoiceBox.getSelectionModel().isEmpty()) return resources.getString("ui.error.no-device"); - int modelChannels = modelChoiceBox.getSelectionModel().getSelectedItem().getNumChannels(); - int selectedChannels = comboChannels.getCheckModel().getCheckedItems().size(); - if (modelChannels != Integer.MAX_VALUE) { - if (modelChannels != selectedChannels) { - return String.format( - resources.getString("ui.error.num-channels-dont-match"), - modelChannels, - selectedChannels); + if (modelChoiceBox.getSelectionModel().getSelectedItem().isDownloaded()) { + // shouldn't happen if downloaded anyway! + int modelChannels = 0; + try { + modelChannels = modelChoiceBox.getSelectionModel().getSelectedItem().getNumChannels(); + } catch (IOException e) { + throw new RuntimeException(e); + } + int selectedChannels = comboChannels.getCheckModel().getCheckedItems().size(); + if (modelChannels != Integer.MAX_VALUE) { + if (modelChannels != selectedChannels) { + return String.format( + resources.getString("ui.error.num-channels-dont-match"), + modelChannels, + selectedChannels); + } } } return null; diff --git a/src/main/java/qupath/ext/instanseg/ui/Watcher.java b/src/main/java/qupath/ext/instanseg/ui/Watcher.java index cea0d8f..5b790de 100644 --- a/src/main/java/qupath/ext/instanseg/ui/Watcher.java +++ b/src/main/java/qupath/ext/instanseg/ui/Watcher.java @@ -37,7 +37,7 @@ void register(Path dir) throws IOException { keys.put(key, dir); } - private void unregister(Path dir) { + void unregister(Path dir) { for (var es: keys.entrySet()) { if (es.getValue().equals(dir)) { logger.debug("Unregister: {}", es.getValue()); @@ -98,7 +98,13 @@ void processEvents() { } if (kind == ENTRY_DELETE && InstanSegModel.isValidModel(name)) { Platform.runLater(() -> { - modelChoiceBox.getItems().removeIf(model -> model.getPath().equals(child)); + modelChoiceBox.getItems().removeIf(model -> { + try { + return model.getPath().equals(child); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); }); } diff --git a/src/main/resources/qupath/ext/instanseg/ui/strings.properties b/src/main/resources/qupath/ext/instanseg/ui/strings.properties index f6ecae1..e2be481 100644 --- a/src/main/resources/qupath/ext/instanseg/ui/strings.properties +++ b/src/main/resources/qupath/ext/instanseg/ui/strings.properties @@ -31,6 +31,7 @@ ui.run = Run ui.run.tooltip = Run the selected model ui.error.no-selection = No annotation, TMA core, or detection selected ui.error.no-model = No model selected +ui.error.model-not-downloaded = Model has not been downloaded yet ui.error.no-device = No device selected ui.error.no-image = No image selected ui.error.num-channels-dont-match = Model requires %d channels, input has %d @@ -88,6 +89,7 @@ ui.stop-tasks = Stop all running tasks? error.window = Error initializing InstanSeg window.\nAn internet connection is required when running for the first time. error.no-imagedata = Cannot run InstanSeg without ImageData. error.downloading = Error downloading files +error.fetching = Error querying local files error.localModel = Can't find file in user model directory error.tiles-failed = %d tiles failed. This is often a memory issue.\nConsider decreasing tile size or the number of threads used. error.modelPath = Unable to fetch model path From f2b9be627396b5d3b8d6f3c5cc94838805299db2 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 28 Aug 2024 18:13:19 +0100 Subject: [PATCH 02/15] Remove todos --- src/main/java/qupath/ext/instanseg/core/InstanSegModel.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index 0fa93cf..a751ed2 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -79,7 +79,6 @@ public static InstanSegModel fromPath(Path path) throws IOException { } public static InstanSegModel fromURL(String name, URL browserDownloadUrl, Path localModelPath) { - // todo: this constructor should initialise a return new InstanSegModel(name, browserDownloadUrl, localModelPath); } @@ -273,10 +272,8 @@ void runInstanSeg( boolean padToInputSize = true; String layout = "CHW"; - // TODO: Remove C if not needed (added for instanseg_v0_2_0.pt) - still relevant? String layoutOutput = "CHW"; - try (var model = Criteria.builder() .setTypes(Mat.class, Mat.class) .optModelUrls(String.valueOf(modelPath.toUri())) From 5a3b85b7980da6ae23fc07359068f6cc88b80507 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 28 Aug 2024 18:40:12 +0100 Subject: [PATCH 03/15] Use optional instead of exceptions --- .../qupath/ext/instanseg/core/InstanSeg.java | 17 +---- .../ext/instanseg/core/InstanSegModel.java | 74 +++++++++++-------- .../ext/instanseg/ui/InstanSegController.java | 72 ++++++++---------- .../ext/instanseg/ui/MessageTextHelper.java | 24 +++--- .../java/qupath/ext/instanseg/ui/Watcher.java | 9 +-- .../ext/instanseg/ui/strings.properties | 2 +- 6 files changed, 89 insertions(+), 109 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSeg.java b/src/main/java/qupath/ext/instanseg/core/InstanSeg.java index 51dbdd5..6d94993 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSeg.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSeg.java @@ -302,15 +302,6 @@ public Builder modelPath(String path) throws IOException { return modelPath(Path.of(path)); } - // /** - // * Set the specific model to be used - // * @param name The name of a built-in model - // * @return A modified builder - // */ - // public Builder modelName(String name) { - // return model(InstanSegModel.fromName(name)); - // } - /** * Set the device to be used * @param deviceName The name of the device to be used (eg, "gpu", "mps"). @@ -410,14 +401,8 @@ public InstanSeg build() { * @param detections The objects to measure. */ public void makeMeasurements(ImageData imageData, Collection detections) { - Double pxSize = null; - try { - pxSize = model.getPixelSizeX(); - } catch (IOException e) { - throw new RuntimeException("Unable to fetch model pixel size", e); - } DetectionMeasurer.builder() - .pixelSize(pxSize) + .pixelSize(model.getPixelSizeX().orElse(0.5)) // todo: is this a good default? .build().makeMeasurements(imageData, detections); } diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index a751ed2..f5460c5 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -39,6 +39,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.zip.ZipEntry; @@ -84,29 +85,27 @@ public static InstanSegModel fromURL(String name, URL browserDownloadUrl, Path l /** * Get the pixel size in the X dimension. + * * @return the pixel size in the X dimension. */ - public Double getPixelSizeX() throws IOException { - return getPixelSize().get("x"); + public Optional getPixelSizeX() { + return getPixelSize().flatMap(p -> Optional.ofNullable(p.get("x"))); } /** * Get the pixel size in the Y dimension. * @return the pixel size in the Y dimension. */ - public Double getPixelSizeY() throws IOException { - return getPixelSize().get("y"); + public Optional getPixelSizeY() { + return getPixelSize().flatMap(p -> Optional.ofNullable(p.get("y"))); } /** * Get the path where the model is stored on disk. - * @return A path on disk, or an exception if it can't be found. + * @return A path on disk. */ - public Path getPath() throws IOException { - if (path == null) { - download(); - } - return path; + public Optional getPath() { + return Optional.ofNullable(path); } @Override @@ -149,11 +148,8 @@ public String getName() { * Retrieve the BioImage model spec. * @return The BioImageIO model spec for this InstanSeg model. */ - BioimageIoSpec.BioimageIoModel getModel() throws IOException { - if (model == null) { - download(); - } - return model; + Optional getModel() { + return Optional.ofNullable(model); } public void download() throws IOException { @@ -168,7 +164,6 @@ public void download() throws IOException { } private static Path downloadZip(URL url, Path localDirectory, String filename) { - // "https://github.com/instanseg/instanseg/releases/download/instanseg_models_v1/fluorescence_nuclei_and_cells.zip" var zipFile = localDirectory.resolve(Path.of(filename + ".zip")); if (!Files.exists(zipFile)) { try (InputStream stream = url.openStream()) { @@ -227,22 +222,33 @@ private static void extractFile(ZipInputStream zipIn, Path filePath) throws IOEx } - private Map getPixelSize() throws IOException { + private Optional> getPixelSize() { + var model = getModel(); + if (model.isEmpty()) return Optional.empty(); var map = new HashMap(); - var config = (LinkedTreeMap)getModel().getConfig().get("qupath"); + var config = (LinkedTreeMap)model.get().getConfig().get("qupath"); var axes = (ArrayList)config.get("axes"); map.put("x", (Double) ((LinkedTreeMap)(axes.get(0))).get("step")); map.put("y", (Double) ((LinkedTreeMap)(axes.get(1))).get("step")); - return map; + return Optional.of(map); } - public int getNumChannels() throws IOException { - assert getModel().getInputs().getFirst().getAxes().equals("bcyx"); - int numChannels = getModel().getInputs().getFirst().getShape().getShapeMin()[1]; - if (getModel().getInputs().getFirst().getShape().getShapeStep()[1] == 1) { + /** + * Try to check the number of channels in the model. + * @return The integer if the model is downloaded, otherwise empty + * @throws IOException + */ + public Optional getNumChannels() { + var model = getModel(); + if (model.isEmpty()) { + return Optional.empty(); + } + assert model.get().getInputs().getFirst().getAxes().equals("bcyx"); + int numChannels = model.get().getInputs().getFirst().getShape().getShapeMin()[1]; + if (model.get().getInputs().getFirst().getShape().getShapeStep()[1] == 1) { numChannels = Integer.MAX_VALUE; } - return numChannels; + return Optional.of(numChannels); } void runInstanSeg( @@ -259,12 +265,11 @@ void runInstanSeg( TaskRunner taskRunner) { nFailed = 0; - Path modelPath; - try { - modelPath = getPath().resolve("instanseg.pt"); - } catch (IOException e) { - throw new RuntimeException(e); + Optional modelPath = getPath(); + if (modelPath.isEmpty()) { + logger.error("Unable to run model - path is missing!"); } + Path ptPath = modelPath.get().resolve("instanseg.pt"); int nPredictors = 1; // todo: change me? @@ -276,7 +281,7 @@ void runInstanSeg( try (var model = Criteria.builder() .setTypes(Mat.class, Mat.class) - .optModelUrls(String.valueOf(modelPath.toUri())) + .optModelUrls(String.valueOf(ptPath.toUri())) .optProgress(new ProgressBar()) .optDevice(device) // Remove this line if devices are problematic! .optTranslator(new MatTranslator(layout, layoutOutput, nucleiOnly)) @@ -335,9 +340,16 @@ public boolean isDownloaded() { return downloaded; } + /** + * Extract the README from a local file + * @return + * @throws IOException + */ public String getREADME() throws IOException { + if (!downloaded) { + download(); + } var file = path.resolve(name + "_README.md"); return Files.readString(file, StandardCharsets.UTF_8); - } } diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index a3f227c..68af630 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -67,6 +67,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; import java.util.concurrent.ExecutorService; @@ -200,17 +201,15 @@ private void updateChannelPicker(ImageData imageData) { comboChannels.getCheckModel().checkIndices(IntStream.range(0, 3).toArray()); var model = modelChoiceBox.getSelectionModel().selectedItemProperty().get(); if (model != null && model.isDownloaded()) { - int numChannels; - try { - numChannels = model.getNumChannels(); - } catch (IOException e) { - // shouldn't happen if downloaded anyway - throw new RuntimeException(e); - } - if (numChannels != Integer.MAX_VALUE) { - comboChannels.getCheckModel().clearChecks(); - comboChannels.getCheckModel().checkIndices(0, 1, 2); + var modelChannels = model.getNumChannels(); + if (modelChannels.isPresent()) { + int nModelChannels = modelChannels.get(); + if (nModelChannels != Integer.MAX_VALUE) { + comboChannels.getCheckModel().clearChecks(); + comboChannels.getCheckModel().checkIndices(0, 1, 2); + } } + } } else { comboChannels.getCheckModel().checkIndices(IntStream.range(0, imageData.getServer().nChannels()).toArray()); @@ -322,9 +321,7 @@ private void configureRunning() { return true; } if (!model.isDownloaded()) return false; // to enable "download and run" - int numSelected = comboChannels.getCheckModel().getCheckedIndices().size(); - int numAllowed = model.getNumChannels(); - return !(numSelected == numAllowed || numAllowed == Integer.MAX_VALUE); + return false; }, modelChoiceBox.getSelectionModel().selectedItemProperty(), needsUpdating)) ); pendingTask.addListener((observable, oldValue, newValue) -> { @@ -348,14 +345,10 @@ private void configureModelChoices() { downloadButton.setDisable(n.isDownloaded()); infoButton.setDisable(!n.isDownloaded()); if (!n.isDownloaded() || qupath.getImageData() == null) return; - try { - if (qupath.getImageData().isBrightfield() && n.getNumChannels() != Integer.MAX_VALUE) { - comboChannels.getCheckModel().clearChecks(); - comboChannels.getCheckModel().checkIndices(0, 1, 2); - } - } catch (IOException e) { - // shouldn't happen if model is downloaded... - throw new RuntimeException(e); + var numChannels = n.getNumChannels(); + if (qupath.getImageData().isBrightfield() && numChannels.isPresent() && numChannels.get() != Integer.MAX_VALUE) { + comboChannels.getCheckModel().clearChecks(); + comboChannels.getCheckModel().checkIndices(0, 1, 2); } }); downloadButton.setOnAction(e -> { @@ -537,20 +530,19 @@ private void runInstanSeg() { } } - try { - int imageChannels = selectedChannels.size(); - int modelChannels = model.getNumChannels(); - if (modelChannels != Integer.MAX_VALUE || model.getNumChannels() != imageChannels) { - Dialogs.showErrorNotification(resources.getString("title"), String.format( - resources.getString("ui.error.num-channels-dont-match"), - modelChannels, imageChannels)); - return; - } - } catch (IOException e) { + int imageChannels = selectedChannels.size(); + var modelChannels = model.getNumChannels(); + if (!modelChannels.isPresent()) { Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.fetching")); return; } - + int nModelChannels = modelChannels.get(); + if (!(nModelChannels == Integer.MAX_VALUE || nModelChannels != imageChannels)) { + Dialogs.showErrorNotification(resources.getString("title"), String.format( + resources.getString("ui.error.num-channels-dont-match"), + nModelChannels, imageChannels)); + return; + } var task = new InstanSegTask(server, model, selectedChannels); pendingTask.set(task); @@ -588,14 +580,12 @@ protected Void call() { var imageData = qupath.getImageData(); var selectedObjects = imageData.getHierarchy().getSelectionModel().getSelectedObjects(); - double pixelSize; - Path path; - try { - pixelSize = model.getPixelSizeX(); - path = model.getPath(); - } catch (IOException e) { - throw new RuntimeException("Unable to fetch model pixel size", e); + Optional pixelSize = model.getPixelSizeX(); + Optional path = model.getPath(); + if (pixelSize.isEmpty() || path.isEmpty()) { + Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.querying-local")); } + var instanSeg = InstanSeg.builder() .model(model) .imageData(imageData) @@ -603,7 +593,7 @@ protected Void call() { .numOutputChannels(nucleiOnlyCheckBox.isSelected() ? 1 : 2) .channels(channels.stream().map(ChannelSelectItem::getTransform).toList()) .tileDims(InstanSegPreferences.tileSizeProperty().get()) - .downsample(pixelSize / (double) server.getPixelCalibration().getAveragedPixelSize()) + .downsample(pixelSize.get() / (double) server.getPixelCalibration().getAveragedPixelSize()) .taskRunner(taskRunner) .build(); instanSeg.detectObjects(selectedObjects); @@ -630,7 +620,7 @@ protected Void call() { nucleiOnlyCheckBox.isSelected() ? 1 : 2, ChannelSelectItem.toConstructorString(channels), InstanSegPreferences.tileSizeProperty().get(), - pixelSize / (double) server.getPixelCalibration().getAveragedPixelSize(), + pixelSize.get() / (double) server.getPixelCalibration().getAveragedPixelSize(), InstanSegPreferences.numThreadsProperty().getValue() ); if (makeMeasurementsCheckBox.isSelected()) { diff --git a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java index 0123eca..e3ae86f 100644 --- a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java +++ b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java @@ -130,19 +130,17 @@ private String getWarningText() { return resources.getString("ui.error.no-device"); if (modelChoiceBox.getSelectionModel().getSelectedItem().isDownloaded()) { // shouldn't happen if downloaded anyway! - int modelChannels = 0; - try { - modelChannels = modelChoiceBox.getSelectionModel().getSelectedItem().getNumChannels(); - } catch (IOException e) { - throw new RuntimeException(e); - } - int selectedChannels = comboChannels.getCheckModel().getCheckedItems().size(); - if (modelChannels != Integer.MAX_VALUE) { - if (modelChannels != selectedChannels) { - return String.format( - resources.getString("ui.error.num-channels-dont-match"), - modelChannels, - selectedChannels); + var modelChannels = modelChoiceBox.getSelectionModel().getSelectedItem().getNumChannels(); + if (modelChannels.isPresent()) { + int nModelChannels = modelChannels.get(); + int selectedChannels = comboChannels.getCheckModel().getCheckedItems().size(); + if (nModelChannels != Integer.MAX_VALUE) { + if (nModelChannels != selectedChannels) { + return String.format( + resources.getString("ui.error.num-channels-dont-match"), + nModelChannels, + selectedChannels); + } } } } diff --git a/src/main/java/qupath/ext/instanseg/ui/Watcher.java b/src/main/java/qupath/ext/instanseg/ui/Watcher.java index 5b790de..3110fcc 100644 --- a/src/main/java/qupath/ext/instanseg/ui/Watcher.java +++ b/src/main/java/qupath/ext/instanseg/ui/Watcher.java @@ -98,13 +98,8 @@ void processEvents() { } if (kind == ENTRY_DELETE && InstanSegModel.isValidModel(name)) { Platform.runLater(() -> { - modelChoiceBox.getItems().removeIf(model -> { - try { - return model.getPath().equals(child); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); + modelChoiceBox.getItems().removeIf(model -> + model.getPath().map(p -> p.equals(child)).orElse(false)); }); } diff --git a/src/main/resources/qupath/ext/instanseg/ui/strings.properties b/src/main/resources/qupath/ext/instanseg/ui/strings.properties index e2be481..58b2e1c 100644 --- a/src/main/resources/qupath/ext/instanseg/ui/strings.properties +++ b/src/main/resources/qupath/ext/instanseg/ui/strings.properties @@ -89,7 +89,7 @@ ui.stop-tasks = Stop all running tasks? error.window = Error initializing InstanSeg window.\nAn internet connection is required when running for the first time. error.no-imagedata = Cannot run InstanSeg without ImageData. error.downloading = Error downloading files -error.fetching = Error querying local files +error.querying-local = Error querying local files error.localModel = Can't find file in user model directory error.tiles-failed = %d tiles failed. This is often a memory issue.\nConsider decreasing tile size or the number of threads used. error.modelPath = Unable to fetch model path From 77f51915a53cc85c1f88d8b4a5798114c957c56e Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 28 Aug 2024 18:51:37 +0100 Subject: [PATCH 04/15] Use optional for README and re-order methods --- .../ext/instanseg/core/InstanSegModel.java | 105 +++++++++++------- .../ext/instanseg/ui/InstanSegController.java | 16 ++- 2 files changed, 76 insertions(+), 45 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index f5460c5..7ccbe15 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -79,10 +79,36 @@ public static InstanSegModel fromPath(Path path) throws IOException { return new InstanSegModel(BioimageIoSpec.parseModel(path.toFile())); } + /** + * Create an InstanSeg model from a remote URL. + * @param name The model name + * @param browserDownloadUrl The download URL from eg GitHub + * @param localModelPath The path that models should be downloaded to + * @return A handle on the created model + */ public static InstanSegModel fromURL(String name, URL browserDownloadUrl, Path localModelPath) { return new InstanSegModel(name, browserDownloadUrl, localModelPath); } + + /** + * Check if the model has been downloaded already. + * @return True if a flag has been set. + */ + public boolean isDownloaded() { + // todo: this should also check if the contents are what we expect + return downloaded; + } + + /** + * Extract the README from a local file + * @return The README as a String, if possible. If not present, or an error + * occurs when reading, nothing. + */ + public Optional getREADME() { + return getPath().map(this::getREADMEString); + } + /** * Get the pixel size in the X dimension. * @@ -145,24 +171,47 @@ public String getName() { } /** - * Retrieve the BioImage model spec. - * @return The BioImageIO model spec for this InstanSeg model. + * Trigger a download for a model + * @throws IOException If an error occurs when downloading, unzipping, etc. */ - Optional getModel() { - return Optional.ofNullable(model); - } - public void download() throws IOException { if (downloaded) return; var zipFile = downloadZip( - this.modelURL, - localModelPath, - name); + this.modelURL, + localModelPath, + name); this.path = unzip(zipFile); this.model = BioimageIoSpec.parseModel(path.toFile()); this.downloaded = true; } + /** + * Try to check the number of channels in the model. + * @return The integer if the model is downloaded, otherwise empty + * @throws IOException + */ + public Optional getNumChannels() { + var model = getModel(); + if (model.isEmpty()) { + return Optional.empty(); + } + assert model.get().getInputs().getFirst().getAxes().equals("bcyx"); + int numChannels = model.get().getInputs().getFirst().getShape().getShapeMin()[1]; + if (model.get().getInputs().getFirst().getShape().getShapeStep()[1] == 1) { + numChannels = Integer.MAX_VALUE; + } + return Optional.of(numChannels); + } + + /** + * Retrieve the BioImage model spec. + * @return The BioImageIO model spec for this InstanSeg model. + */ + Optional getModel() { + return Optional.ofNullable(model); + } + + private static Path downloadZip(URL url, Path localDirectory, String filename) { var zipFile = localDirectory.resolve(Path.of(filename + ".zip")); if (!Files.exists(zipFile)) { @@ -233,23 +282,7 @@ private Optional> getPixelSize() { return Optional.of(map); } - /** - * Try to check the number of channels in the model. - * @return The integer if the model is downloaded, otherwise empty - * @throws IOException - */ - public Optional getNumChannels() { - var model = getModel(); - if (model.isEmpty()) { - return Optional.empty(); - } - assert model.get().getInputs().getFirst().getAxes().equals("bcyx"); - int numChannels = model.get().getInputs().getFirst().getShape().getShapeMin()[1]; - if (model.get().getInputs().getFirst().getShape().getShapeStep()[1] == 1) { - numChannels = Integer.MAX_VALUE; - } - return Optional.of(numChannels); - } + void runInstanSeg( ImageData imageData, @@ -336,20 +369,14 @@ private static void printResourceCount(String title, BaseNDManager manager) { manager.debugDump(2); } - public boolean isDownloaded() { - return downloaded; - } - /** - * Extract the README from a local file - * @return - * @throws IOException - */ - public String getREADME() throws IOException { - if (!downloaded) { - download(); - } + private String getREADMEString(Path path) { var file = path.resolve(name + "_README.md"); - return Files.readString(file, StandardCharsets.UTF_8); + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + logger.error("Unable to find README", e); + return null; + } } } diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 68af630..78b78ca 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -120,6 +120,12 @@ public class InstanSegController extends BorderPane { private final Watcher watcher = new Watcher(modelChoiceBox); private ExecutorService executor; + /** + * Create an instance of the InstanSeg GUI pane. + * @param qupath The QuPath GUI it should be attached to. + * @return A handle on the UI element. + * @throws IOException If the FXML or resources fail to load. + */ public static InstanSegController createInstance(QuPathGUI qupath) throws IOException { return new InstanSegController(qupath); } @@ -373,12 +379,10 @@ private void configureModelChoices() { private static void parseMarkdown(InstanSegModel model, WebView webView, Button infoButton, PopOver infoPopover) { - String body = null; - try { - body = model.getREADME(); - } catch (IOException e) { - throw new RuntimeException(e); - } + Optional readme = model.getREADME(); + if (readme.isEmpty()) return; + String body = readme.get(); + // Parse the initial markdown only, to extract any YAML front matter var parser = org.commonmark.parser.Parser.builder().build(); var doc = parser.parse(body); From 98f92838f1868737d32d30aec70afab001a05e40 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Thu, 29 Aug 2024 00:33:46 +0100 Subject: [PATCH 05/15] Remove redundant bool --- .../ext/instanseg/core/InstanSegModel.java | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index 9d3239e..7d6be57 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -49,7 +49,6 @@ public class InstanSegModel { private static final Logger logger = LoggerFactory.getLogger(InstanSegModel.class); private Path localModelPath; private URL modelURL = null; - private boolean downloaded = false; private Path path = null; private BioimageIoSpec.BioimageIoModel model = null; @@ -60,7 +59,6 @@ private InstanSegModel(BioimageIoSpec.BioimageIoModel bioimageIoModel) { this.model = bioimageIoModel; this.path = Paths.get(model.getBaseURI()); this.name = model.getName(); - this.downloaded = true; } private InstanSegModel(String name, URL modelURL, Path localModelPath) { @@ -97,7 +95,7 @@ public static InstanSegModel fromURL(String name, URL browserDownloadUrl, Path l */ public boolean isDownloaded() { // todo: this should also check if the contents are what we expect - return downloaded; + return path != null && model != null; } /** @@ -175,20 +173,18 @@ public String getName() { * @throws IOException If an error occurs when downloading, unzipping, etc. */ public void download() throws IOException { - if (downloaded) return; + if (path != null && model != null) return; var zipFile = downloadZip( this.modelURL, localModelPath, name); this.path = unzip(zipFile); this.model = BioimageIoSpec.parseModel(path.toFile()); - this.downloaded = true; } /** * Try to check the number of channels in the model. * @return The integer if the model is downloaded, otherwise empty - * @throws IOException */ public Optional getNumChannels() { var model = getModel(); @@ -263,7 +259,7 @@ private static void unzip(Path zipFile, Path destination) throws IOException { private static void extractFile(ZipInputStream zipIn, Path filePath) throws IOException { BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(filePath.toFile())); byte[] bytesIn = new byte[4096]; - int read = 0; + int read; while ((read = zipIn.read(bytesIn)) != -1) { bos.write(bytesIn, 0, read); } @@ -275,10 +271,10 @@ private Optional> getPixelSize() { var model = getModel(); if (model.isEmpty()) return Optional.empty(); var map = new HashMap(); - var config = (LinkedTreeMap)model.get().getConfig().get("qupath"); - var axes = (ArrayList)config.get("axes"); - map.put("x", (Double) ((LinkedTreeMap)(axes.get(0))).get("step")); - map.put("y", (Double) ((LinkedTreeMap)(axes.get(1))).get("step")); + var config = (LinkedTreeMap)model.get().getConfig().get("qupath"); + var axes = (ArrayList)config.get("axes"); + map.put("x", (Double) ((LinkedTreeMap)(axes.get(0))).get("step")); + map.put("y", (Double) ((LinkedTreeMap)(axes.get(1))).get("step")); return Optional.of(map); } @@ -367,8 +363,8 @@ void runInstanSeg( /** * Print resource count for debugging purposes. * If we are not logging at debug level, do nothing. - * @param title - * @param manager + * @param title The title to put above the log messages + * @param manager The NDManager to dump the logs from */ private static void printResourceCount(String title, BaseNDManager manager) { if (logger.isDebugEnabled()) { From be67023def68e0396daf9479b07b3943ce992ff3 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Thu, 29 Aug 2024 10:13:30 +0100 Subject: [PATCH 06/15] Replace file.exists with dummy method calls --- .../qupath/ext/instanseg/core/InstanSegModel.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index 7d6be57..9db95e7 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -210,7 +210,7 @@ Optional getModel() { private static Path downloadZip(URL url, Path localDirectory, String filename) { var zipFile = localDirectory.resolve(Path.of(filename + ".zip")); - if (!Files.exists(zipFile)) { + if (!isDownloadedAlready(zipFile)) { try (InputStream stream = url.openStream()) { try (ReadableByteChannel readableByteChannel = Channels.newChannel(stream)) { try (FileOutputStream fos = new FileOutputStream(zipFile.toFile())) { @@ -224,9 +224,14 @@ private static Path downloadZip(URL url, Path localDirectory, String filename) { return zipFile; } + private static boolean isDownloadedAlready(Path zipFile) { + // todo: validate contents somehow + return Files.exists(zipFile); + } + private static Path unzip(Path zipFile) { var outdir = zipFile.resolveSibling(zipFile.getFileName().toString().replace(".zip", "")); - if (!Files.exists(outdir)) { + if (!isUnpackedAlready(outdir)) { try { unzip(zipFile, zipFile.getParent()); // Files.delete(zipFile); @@ -237,6 +242,10 @@ private static Path unzip(Path zipFile) { return outdir; } + private static boolean isUnpackedAlready(Path outdir) { + return Files.exists(outdir) && isValidModel(outdir); + } + private static void unzip(Path zipFile, Path destination) throws IOException { if (!Files.exists(destination)) { Files.createDirectory(destination); From 9378900b3d5c48499df388319563d739510ec4f8 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 4 Sep 2024 16:15:47 +0100 Subject: [PATCH 07/15] Detect objects in script --- .../qupath/ext/instanseg/ui/InstanSegController.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index ad32ef5..c309472 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -540,10 +540,11 @@ private void runInstanSeg() { int imageChannels = selectedChannels.size(); var modelChannels = model.getNumChannels(); - if (!modelChannels.isPresent()) { + if (modelChannels.isEmpty()) { Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.fetching")); return; } + int nModelChannels = modelChannels.get(); if (!(nModelChannels == Integer.MAX_VALUE || nModelChannels != imageChannels)) { Dialogs.showErrorNotification(resources.getString("title"), String.format( @@ -561,6 +562,7 @@ private void runInstanSeg() { pendingTask.set(null); } }); + } private class InstanSegTask extends Task { @@ -592,6 +594,7 @@ protected Void call() { Optional path = model.getPath(); if (pixelSize.isEmpty() || path.isEmpty()) { Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.querying-local")); + return null; } var instanSeg = InstanSeg.builder() @@ -618,24 +621,27 @@ protected Void call() { .build() .%s """, - path, + path.get(), deviceChoices.getSelectionModel().getSelectedItem(), nucleiOnlyCheckBox.isSelected() ? 1 : 2, ChannelSelectItem.toConstructorString(channels), InstanSegPreferences.tileSizeProperty().get(), pixelSize.get() / (double) server.getPixelCalibration().getAveragedPixelSize(), - InstanSegPreferences.numThreadsProperty().getValue() + InstanSegPreferences.numThreadsProperty().getValue(), + makeMeasurements ? "detectObjectsAndMeasure()" : "detectObjects()" ); if (makeMeasurements) { instanSeg.detectObjectsAndMeasure(selectedObjects); } else { instanSeg.detectObjects(selectedObjects); } + imageData.getHierarchy().fireHierarchyChangedEvent(this); imageData.getHistoryWorkflow() .addStep( new DefaultScriptableWorkflowStep(resources.getString("workflow.title"), cmd) ); + if (model.nFailed() > 0) { var errorMessage = String.format(resources.getString("error.tiles-failed"), model.nFailed()); logger.error(errorMessage); From df06e63e58f2d1c8a0e194c7864e36aa538ab508 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 4 Sep 2024 18:24:20 +0100 Subject: [PATCH 08/15] Use downsample instead of pixelSize --- .../ext/instanseg/core/DetectionMeasurer.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/DetectionMeasurer.java b/src/main/java/qupath/ext/instanseg/core/DetectionMeasurer.java index 55979f9..1534bbb 100644 --- a/src/main/java/qupath/ext/instanseg/core/DetectionMeasurer.java +++ b/src/main/java/qupath/ext/instanseg/core/DetectionMeasurer.java @@ -28,9 +28,9 @@ private DetectionMeasurer(Collection compartmen Collection shapeFeatures, double downsample) { this.shapeFeatures = shapeFeatures; - this.downsample = downsample; this.compartments = compartments; this.measurements = measurements; + this.downsample = downsample; } /** @@ -93,15 +93,15 @@ public static class Builder { .filter(m -> m != ObjectMeasurements.Measurements.VARIANCE) // Skip variance - we have standard deviation .toList(); private Collection shapeFeatures = Arrays.asList(ObjectMeasurements.ShapeFeatures.values()); - private double pixelSize; + private double downsample; /** - * Set the pixel size that measurements should be made at. - * @param pixelSize The pixel size that detections/annotations/etc were made at. - * @return A modified builder. + * Set the desired downsample. + * @param downsample + * @return */ - public Builder pixelSize(double pixelSize) { - this.pixelSize = pixelSize; + public Builder downsample(double downsample) { + this.downsample = downsample; return this; } @@ -140,7 +140,7 @@ public Builder shapeFeatures(Collection shapeF * @return An immutable detection measurer. */ public DetectionMeasurer build() { - return new DetectionMeasurer(compartments, measurements, shapeFeatures, pixelSize); + return new DetectionMeasurer(compartments, measurements, shapeFeatures, downsample); } } } From 2d513a71b13d59303453f17facc71769a587047d Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 4 Sep 2024 19:56:11 +0100 Subject: [PATCH 09/15] Cache release information locally --- .../ext/instanseg/core/InstanSegModel.java | 11 +++-- .../ext/instanseg/ui/InstanSegController.java | 41 +++++++++++++++---- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index e8b5e78..ee1bf72 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -115,18 +115,17 @@ public Optional getREADME() { /** * Get the pixel size in the X dimension. - * - * @return the pixel size in the X dimension. + * @return the pixel size in the X dimension, or empty if the model isn't downloaded yet. */ - public Optional getPixelSizeX() { + private Optional getPixelSizeX() { return getPixelSize().flatMap(p -> Optional.ofNullable(p.getOrDefault("x", null))); } /** * Get the pixel size in the Y dimension. - * @return the pixel size in the Y dimension. + * @return the pixel size in the Y dimension, or empty if the model isn't downloaded yet. */ - public Optional getPixelSizeY() { + private Optional getPixelSizeY() { return getPixelSize().flatMap(p -> Optional.ofNullable(p.getOrDefault("y", null))); } @@ -134,7 +133,7 @@ public Optional getPixelSizeY() { * Get the preferred pixel size for running the model, in the absence of any other information. * This is the average of the X and Y pixel sizes if both are available. * Otherwise, it is the value of whichever value is available - or null if neither is found. - * @return the pixel size + * @return the pixel size, or empty if the model isn't downloaded yet. */ public Optional getPreferredPixelSize() { var x = getPixelSizeX(); diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 73e19d2..13b4ce2 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -158,7 +158,9 @@ private InstanSegController(QuPathGUI qupath) throws IOException { modelChoiceBox.getSelectionModel().selectedItemProperty(), needsUpdating); infoButton.disableProperty().bind(currentModelIsDownloaded.not()); - downloadButton.disableProperty().bind(currentModelIsDownloaded); + downloadButton.disableProperty().bind( + currentModelIsDownloaded.or(modelChoiceBox.getSelectionModel().selectedItemProperty().isNull()) + ); configureChannelPicker(); } @@ -467,6 +469,9 @@ private void handleModelDirectory(String n) { if (Files.exists(path) && Files.isDirectory(path)) { try { var localPath = path.resolve("local"); + if (!Files.exists(localPath)) { + Files.createDirectory(localPath); + } watcher.register(localPath); // todo: unregister addModelsFromPath(localPath, modelChoiceBox); } catch (IOException e) { @@ -611,9 +616,8 @@ protected Void call() { var imageData = qupath.getImageData(); var selectedObjects = imageData.getHierarchy().getSelectionModel().getSelectedObjects(); - Optional pixelSize = model.getPixelSizeX(); Optional path = model.getPath(); - if (pixelSize.isEmpty() || path.isEmpty()) { + if (path.isEmpty()) { Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.querying-local")); return null; } @@ -625,7 +629,6 @@ protected Void call() { .numOutputChannels(nucleiOnlyCheckBox.isSelected() ? 1 : 2) .channels(channels.stream().map(ChannelSelectItem::getTransform).toList()) .tileDims(InstanSegPreferences.tileSizeProperty().get()) - .downsample(pixelSize.get().doubleValue() / (double) server.getPixelCalibration().getAveragedPixelSize()) .taskRunner(taskRunner) .build(); @@ -646,7 +649,6 @@ protected Void call() { nucleiOnlyCheckBox.isSelected() ? 1 : 2, ChannelSelectItem.toConstructorString(channels), InstanSegPreferences.tileSizeProperty().get(), - pixelSize.get().doubleValue() / (double) server.getPixelCalibration().getAveragedPixelSize(), InstanSegPreferences.numThreadsProperty().getValue(), makeMeasurements ? "detectObjectsAndMeasure()" : "detectObjects()" ); @@ -759,21 +761,44 @@ public String getName() { } } + /** + * Get the list of models from the latest GitHub release, downloading if + * necessary. + * @return A list of GitHub releases, possibly empty. + */ private static List getReleases() { + Path cachedReleases = Path.of(InstanSegPreferences.modelDirectoryProperty().get(), "releases.json"); String uString = "https://api.github.com/repos/instanseg/InstanSeg/releases"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uString)) .GET() .build(); - String response; + HttpResponse response; + String json; + // check github api for releases try (HttpClient client = HttpClient.newHttpClient()) { - response = client.send(request, HttpResponse.BodyHandlers.ofString()).body(); + response = client.send(request, HttpResponse.BodyHandlers.ofString()); + // if response is okay, then cache it + if (response.statusCode() == 200) { + json = response.body(); + Files.writeString(cachedReleases, json); + } else { + // if not, try to fall back on a cached version + if (Files.exists(cachedReleases)) { + json = Files.readString(cachedReleases); + // no cache, give up! + } else { + logger.warn("Unable to fetch release information from GitHub and no cached version available."); + return List.of(); + } + } } catch (IOException | InterruptedException e) { logger.error("Unable to fetch GitHub release information", e); return List.of(); } + Gson gson = new Gson(); - var releases = gson.fromJson(response, GitHubRelease[].class); + var releases = gson.fromJson(json, GitHubRelease[].class); if (!(releases.length > 0)) { return List.of(); } From b42cc8defd6cb8810f1d12bedaad85ba7c95a4ce Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 4 Sep 2024 20:08:50 +0100 Subject: [PATCH 10/15] Cache releases JSON --- .../ext/instanseg/core/InstanSegModel.java | 7 ++---- .../ext/instanseg/ui/InstanSegController.java | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index ee1bf72..9e8595a 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -79,12 +79,11 @@ public static InstanSegModel fromURL(String name, URL browserDownloadUrl) { */ public boolean isDownloaded(Path localModelPath) { // todo: this should also check if the contents are what we expect - // todo: this should also try to instantiate...? if (Files.exists(localModelPath.resolve(name))) { try { download(localModelPath); } catch (IOException e) { - logger.error("Model dir exists but is not valid", e); + logger.error("Model directory exists but is not valid", e); } } return path != null && model != null; @@ -261,7 +260,7 @@ private Optional getModel() { } - private static Path downloadZipIfNeeded(URL url, Path localDirectory, String filename) { + private static Path downloadZipIfNeeded(URL url, Path localDirectory, String filename) throws IOException { var zipFile = localDirectory.resolve(Path.of(filename + ".zip")); if (!isDownloadedAlready(zipFile)) { try (InputStream stream = url.openStream()) { @@ -270,8 +269,6 @@ private static Path downloadZipIfNeeded(URL url, Path localDirectory, String fil fos.getChannel().transferFrom(readableByteChannel, 0, Long.MAX_VALUE); } } - } catch (IOException e) { - throw new RuntimeException(e); } } return zipFile; diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 13b4ce2..48153fa 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -425,7 +425,7 @@ private static void parseMarkdown(InstanSegModel model, WebView webView, Button private static void addRemoteModels(ComboBox comboBox) { var releases = getReleases(); if (releases.isEmpty()) { - logger.info("No releases found"); + logger.info("No releases found."); return; } var release = releases.getFirst(); @@ -775,7 +775,7 @@ private static List getReleases() { .build(); HttpResponse response; String json; - // check github api for releases + // check GitHub api for releases try (HttpClient client = HttpClient.newHttpClient()) { response = client.send(request, HttpResponse.BodyHandlers.ofString()); // if response is okay, then cache it @@ -783,23 +783,28 @@ private static List getReleases() { json = response.body(); Files.writeString(cachedReleases, json); } else { - // if not, try to fall back on a cached version - if (Files.exists(cachedReleases)) { + // otherwise problems + throw new IOException("Unable to fetch GitHub release information, status " + response.statusCode()); + } + } catch (IOException | InterruptedException e) { + // if not, try to fall back on a cached version + if (Files.exists(cachedReleases)) { + try { json = Files.readString(cachedReleases); - // no cache, give up! - } else { - logger.warn("Unable to fetch release information from GitHub and no cached version available."); + } catch (IOException ex) { + logger.warn("Unable to read cached release information"); return List.of(); } + } else { + logger.info("Unable to fetch release information from GitHub and no cached version available."); + return List.of(); } - } catch (IOException | InterruptedException e) { - logger.error("Unable to fetch GitHub release information", e); - return List.of(); } Gson gson = new Gson(); var releases = gson.fromJson(json, GitHubRelease[].class); if (!(releases.length > 0)) { + logger.info("No releases found in JSON string"); return List.of(); } return List.of(releases); From d83884b47b679e3758a7b5f7fc4e77c79c983f31 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 4 Sep 2024 23:59:28 +0100 Subject: [PATCH 11/15] Fix BF models, pretty-print JSON --- .../ext/instanseg/core/InstanSegModel.java | 8 ++-- .../ext/instanseg/ui/InstanSegController.java | 41 +++++++++++-------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index 9861444..fa71ad5 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -45,7 +45,7 @@ public class InstanSegModel { private InstanSegModel(BioimageIoSpec.BioimageIoModel bioimageIoModel) { this.model = bioimageIoModel; this.path = Paths.get(model.getBaseURI()); - this.name = model.getName(); + this.name = model.getName() + " (local)"; } private InstanSegModel(String name, URL modelURL) { @@ -279,14 +279,16 @@ private static boolean isDownloadedAlready(Path zipFile) { return Files.exists(zipFile); } - private static Path unzipIfNeeded(Path zipFile) { + private static Path unzipIfNeeded(Path zipFile) throws IOException { var outdir = zipFile.resolveSibling(zipFile.getFileName().toString().replace(".zip", "")); if (!isUnpackedAlready(outdir)) { try { unzip(zipFile, zipFile.getParent()); // Files.delete(zipFile); } catch (IOException e) { - throw new RuntimeException(e); + // clean up files just in case! + Files.deleteIfExists(zipFile); + Files.deleteIfExists(outdir); } } return outdir; diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 7e4be3e..9b51022 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -1,6 +1,9 @@ package qupath.ext.instanseg.ui; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; @@ -387,20 +390,21 @@ private void configureModelChoices() { } private void downloadModel() { - ForkJoinPool.commonPool().execute(() -> { - try { - var model = modelChoiceBox.getSelectionModel().getSelectedItem(); - Dialogs.showInfoNotification(resources.getString("title"), - String.format(resources.getString("ui.popup.fetching"), model.getName())); - model.download(Path.of(InstanSegPreferences.modelDirectoryProperty().get())); - Dialogs.showInfoNotification(resources.getString("title"), - String.format(resources.getString("ui.popup.available"), model.getName())); - needsUpdating.set(!needsUpdating.get()); - } catch (IOException ex) { - Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.downloading")); - } - }); - + try (var pool = ForkJoinPool.commonPool()) { + pool.execute(() -> { + try { + var model = modelChoiceBox.getSelectionModel().getSelectedItem(); + Dialogs.showInfoNotification(resources.getString("title"), + String.format(resources.getString("ui.popup.fetching"), model.getName())); + model.download(Path.of(InstanSegPreferences.modelDirectoryProperty().get())); + Dialogs.showInfoNotification(resources.getString("title"), + String.format(resources.getString("ui.popup.available"), model.getName())); + needsUpdating.set(!needsUpdating.get()); + } catch (IOException ex) { + Dialogs.showErrorNotification(resources.getString("title"), resources.getString("error.downloading")); + } + }); + } } private static void parseMarkdown(InstanSegModel model, WebView webView, Button infoButton, PopOver infoPopover) { @@ -573,7 +577,7 @@ private void runInstanSeg() { } int nModelChannels = modelChannels.get(); - if (!(nModelChannels == Integer.MAX_VALUE || nModelChannels != imageChannels)) { + if (!(nModelChannels == InstanSegModel.ANY_CHANNELS || nModelChannels != imageChannels)) { Dialogs.showErrorNotification(resources.getString("title"), String.format( resources.getString("ui.error.num-channels-dont-match"), nModelChannels, imageChannels)); @@ -779,13 +783,15 @@ private static List getReleases() { .build(); HttpResponse response; String json; + Gson gson = new GsonBuilder().setPrettyPrinting().create(); // check GitHub api for releases try (HttpClient client = HttpClient.newHttpClient()) { response = client.send(request, HttpResponse.BodyHandlers.ofString()); // if response is okay, then cache it if (response.statusCode() == 200) { json = response.body(); - Files.writeString(cachedReleases, json); + JsonElement jsonElement = JsonParser.parseString(json); + Files.writeString(cachedReleases, gson.toJson(jsonElement)); } else { // otherwise problems throw new IOException("Unable to fetch GitHub release information, status " + response.statusCode()); @@ -805,8 +811,7 @@ private static List getReleases() { } } - Gson gson = new Gson(); - var releases = gson.fromJson(json, GitHubRelease[].class); + GitHubRelease[] releases = gson.fromJson(json, GitHubRelease[].class); if (!(releases.length > 0)) { logger.info("No releases found in JSON string"); return List.of(); From b766c1d1475753b4c5704d41849f5c5b79c31e03 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Thu, 5 Sep 2024 00:25:30 +0100 Subject: [PATCH 12/15] Fix watcher --- .../ext/instanseg/core/InstanSegModel.java | 1 - .../ext/instanseg/ui/InstanSegController.java | 9 ++-- .../java/qupath/ext/instanseg/ui/Watcher.java | 43 +++++++++++-------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index fa71ad5..c0141db 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -216,7 +216,6 @@ public String toString() { * the yaml contents and the checksum of the pt file. */ public static boolean isValidModel(Path path) { - // return path.toString().endsWith(".pt"); // if just looking at pt files if (Files.isDirectory(path)) { return Files.exists(path.resolve("instanseg.pt")) && Files.exists(path.resolve("rdf.yaml")); } diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 9b51022..75c668f 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -87,6 +87,7 @@ public class InstanSegController extends BorderPane { private static final Logger logger = LoggerFactory.getLogger(InstanSegController.class); private static final ResourceBundle resources = ResourceBundle.getBundle("qupath.ext.instanseg.ui.strings"); + private final Watcher watcher; @FXML private CheckComboBox comboChannels; @@ -119,12 +120,10 @@ public class InstanSegController extends BorderPane { private final ExecutorService pool = Executors.newSingleThreadExecutor(ThreadTools.createThreadFactory("instanseg", true)); private final QuPathGUI qupath; - private ObjectProperty> pendingTask = new SimpleObjectProperty<>(); + private final ObjectProperty> pendingTask = new SimpleObjectProperty<>(); private MessageTextHelper messageTextHelper; private final BooleanProperty needsUpdating = new SimpleBooleanProperty(); - private final Watcher watcher = new Watcher(modelChoiceBox); - private ExecutorService executor; /** * Create an instance of the InstanSeg GUI pane. @@ -143,6 +142,7 @@ private InstanSegController(QuPathGUI qupath) throws IOException { loader.setRoot(this); loader.setController(this); loader.load(); + watcher = new Watcher(modelChoiceBox); configureMessageLabel(); configureTileSizes(); @@ -537,8 +537,7 @@ static void addModelsFromPath(Path path, ComboBox box) { } void restart() { - executor = Executors.newSingleThreadExecutor(); - executor.submit(watcher::processEvents); + Thread.ofVirtual().start(watcher::processEvents); } @FXML diff --git a/src/main/java/qupath/ext/instanseg/ui/Watcher.java b/src/main/java/qupath/ext/instanseg/ui/Watcher.java index 3110fcc..4ef2de0 100644 --- a/src/main/java/qupath/ext/instanseg/ui/Watcher.java +++ b/src/main/java/qupath/ext/instanseg/ui/Watcher.java @@ -1,6 +1,7 @@ package qupath.ext.instanseg.ui; import javafx.application.Platform; +import javafx.collections.FXCollections; import org.controlsfx.control.SearchableComboBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -79,28 +80,28 @@ void processEvents() { for (WatchEvent event : key.pollEvents()) { WatchEvent.Kind kind = event.kind(); WatchEvent ev = cast(event); - Path name = ev.context(); + Path fileName = ev.context(); // Context for directory entry event is the file name of entry - Path child = dir.resolve(name); + Path filePath = dir.resolve(fileName); // print out event - logger.debug("{}: {}", event.kind().name(), child); - - if (kind == ENTRY_CREATE && InstanSegModel.isValidModel(name)) { - Platform.runLater(() -> { - try { - modelChoiceBox.getItems().add(InstanSegModel.fromPath(child)); - } catch (IOException e) { - logger.error("Unable to add model from path", e); - } - }); + logger.debug("{}: {}", event.kind().name(), filePath); + + if (kind == ENTRY_CREATE) { + if (InstanSegModel.isValidModel(filePath)) { + Platform.runLater(() -> { + try { + modelChoiceBox.getItems().add(InstanSegModel.fromPath(filePath)); + } catch (IOException e) { + logger.error("Unable to add model from path", e); + } + }); + } } - if (kind == ENTRY_DELETE && InstanSegModel.isValidModel(name)) { - Platform.runLater(() -> { - modelChoiceBox.getItems().removeIf(model -> - model.getPath().map(p -> p.equals(child)).orElse(false)); - }); + if (kind == ENTRY_DELETE) { + // todo: controller should handle adding/removing logic + removeModel(filePath); } } @@ -121,4 +122,12 @@ void processEvents() { void interrupt() { interrupted = true; } + + private void removeModel(Path filePath) { + // https://github.com/controlsfx/controlsfx/issues/1320 + var items = FXCollections.observableArrayList(modelChoiceBox.getItems()); + var matchingItems = modelChoiceBox.getItems().stream().filter(model -> model.getPath().map(p -> p.equals(filePath)).orElse(false)).toList(); + items.removeAll(matchingItems); + Platform.runLater(() -> modelChoiceBox.setItems(items)); + } } From 68fe8599cfc3bc778db9d6307dc1d91ab235fc89 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 5 Sep 2024 06:13:21 +0100 Subject: [PATCH 13/15] Fix most exceptions There were not checks to ensure that `InstanSegPreferences.modelDirectoryProperty().get()` didn't return null, and therefore lots of `Path.of` calls would fail if it was null. --- .../ext/instanseg/core/InstanSegModel.java | 13 ++- .../ext/instanseg/ui/InstanSegController.java | 84 ++++++++++++++----- .../ext/instanseg/ui/MessageTextHelper.java | 5 +- .../ext/instanseg/ui/strings.properties | 2 + 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java index c0141db..aa7c4a2 100644 --- a/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java +++ b/src/main/java/qupath/ext/instanseg/core/InstanSegModel.java @@ -330,10 +330,15 @@ private static void extractFile(ZipInputStream zipIn, Path filePath) throws IOEx private String getREADMEString(Path path) { var file = path.resolve(name + "_README.md"); - try { - return Files.readString(file, StandardCharsets.UTF_8); - } catch (IOException e) { - logger.error("Unable to find README", e); + if (Files.exists(file)) { + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + logger.error("Unable to find README", e); + return null; + } + } else { + logger.debug("No README found for model {}", name); return null; } } diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 75c668f..6b33f99 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -85,7 +85,9 @@ * Controller for UI pane contained in instanseg_control.fxml */ public class InstanSegController extends BorderPane { + private static final Logger logger = LoggerFactory.getLogger(InstanSegController.class); + private static final ResourceBundle resources = ResourceBundle.getBundle("qupath.ext.instanseg.ui.strings"); private final Watcher watcher; @@ -157,9 +159,11 @@ private InstanSegController(QuPathGUI qupath) throws IOException { if (model == null) { return false; } - return model.isDownloaded(Path.of(InstanSegPreferences.modelDirectoryProperty().get())); + var modelDir = getModelDirectory().orElse(null); + return modelDir != null && model.isDownloaded(modelDir); }, - modelChoiceBox.getSelectionModel().selectedItemProperty(), needsUpdating); + modelChoiceBox.getSelectionModel().selectedItemProperty(), needsUpdating, + InstanSegPreferences.modelDirectoryProperty()); infoButton.disableProperty().bind(currentModelIsDownloaded.not()); downloadButton.disableProperty().bind( @@ -182,15 +186,14 @@ void handleModelDirectoryLabelClick(MouseEvent event) { if (event.getClickCount() != 2) { return; } - var path = InstanSegPreferences.modelDirectoryProperty().get(); - if (path == null || path.isEmpty()) { + var modelDir = getModelDirectory().orElse(null); + if (modelDir == null) { return; } - var file = new File(path); - if (file.exists()) { - GuiTools.browseDirectory(file); + if (Files.exists(modelDir)) { + GuiTools.browseDirectory(modelDir.toFile()); } else { - logger.debug("Can't browse directory for {}", file); + logger.debug("Can't browse directory for {}", modelDir); } } @@ -345,7 +348,11 @@ private void configureRunning() { if (model == null) { return true; } - if (!model.isDownloaded(Path.of(InstanSegPreferences.modelDirectoryProperty().get()))) { + var modelDir = getModelDirectory().orElse(null); + if (modelDir == null || !Files.exists(modelDir)) { + return true; // Can't download without somewhere to put it + } + if (!model.isDownloaded(modelDir)) { return false; // to enable "download and run" } return false; @@ -358,12 +365,28 @@ private void configureRunning() { }); } + private static Path tryToGetPath(String path) { + if (path == null || path.isEmpty()) { + return null; + } else { + return Path.of(path); + } + } + + static Optional getModelDirectory() { + var path = InstanSegPreferences.modelDirectoryProperty().get(); + return Optional.ofNullable(tryToGetPath(path)); + } + private void configureModelChoices() { tfModelDirectory.textProperty().bindBidirectional(InstanSegPreferences.modelDirectoryProperty()); handleModelDirectory(tfModelDirectory.getText()); addRemoteModels(modelChoiceBox); tfModelDirectory.textProperty().addListener((v, o, n) -> { - watcher.unregister(Path.of(o)); + var oldModelDir = tryToGetPath(o); + if (oldModelDir != null && Files.exists(oldModelDir)) { + watcher.unregister(oldModelDir); + } handleModelDirectory(n); addRemoteModels(modelChoiceBox); }); @@ -371,7 +394,8 @@ private void configureModelChoices() { if (n == null) { return; } - boolean isDownloaded = n.isDownloaded(Path.of(InstanSegPreferences.modelDirectoryProperty().get())); + var modelDir = getModelDirectory().orElse(null); + boolean isDownloaded = modelDir != null && n.isDownloaded(modelDir); if (!isDownloaded || qupath.getImageData() == null) { return; } @@ -393,10 +417,16 @@ private void downloadModel() { try (var pool = ForkJoinPool.commonPool()) { pool.execute(() -> { try { + var modelDir = getModelDirectory().orElse(null); + if (modelDir == null || !Files.exists(modelDir)) { + logger.warn("Can't download model {}, model directory not found", + modelChoiceBox.getSelectionModel().getSelectedItem()); + return; + } var model = modelChoiceBox.getSelectionModel().getSelectedItem(); Dialogs.showInfoNotification(resources.getString("title"), String.format(resources.getString("ui.popup.fetching"), model.getName())); - model.download(Path.of(InstanSegPreferences.modelDirectoryProperty().get())); + model.download(modelDir); Dialogs.showInfoNotification(resources.getString("title"), String.format(resources.getString("ui.popup.available"), model.getName())); needsUpdating.set(!needsUpdating.get()); @@ -469,8 +499,9 @@ private static void overrideToggleSelected(ToggleButton button) { } private void handleModelDirectory(String n) { - if (n == null) return; - var path = Path.of(n); + var path = tryToGetPath(n); + if (path == null) + return; if (Files.exists(path) && Files.isDirectory(path)) { try { var localPath = path.resolve("local"); @@ -557,9 +588,14 @@ private void runInstanSeg() { var model = modelChoiceBox.getSelectionModel().getSelectedItem(); - var modelPath = Path.of(InstanSegPreferences.modelDirectoryProperty().get()); + var modelPath = getModelDirectory().orElse(null); + if (modelPath == null) { + Dialogs.showErrorNotification(resources.getString("title"), resources.getString("ui.model-directory.choose-prompt")); + return; + } if (!model.isDownloaded(modelPath)) { - if (!Dialogs.showYesNoDialog(resources.getString("title"), resources.getString("ui.model-popup"))) return; + if (!Dialogs.showYesNoDialog(resources.getString("title"), resources.getString("ui.model-popup"))) + return; Dialogs.showInfoNotification(resources.getString("title"), String.format(resources.getString("ui.popup.fetching"), model.getName())); downloadModel(); if (!model.isDownloaded(modelPath)) { @@ -576,7 +612,7 @@ private void runInstanSeg() { } int nModelChannels = modelChannels.get(); - if (!(nModelChannels == InstanSegModel.ANY_CHANNELS || nModelChannels != imageChannels)) { + if (nModelChannels != InstanSegModel.ANY_CHANNELS && nModelChannels != imageChannels) { Dialogs.showErrorNotification(resources.getString("title"), String.format( resources.getString("ui.error.num-channels-dont-match"), nModelChannels, imageChannels)); @@ -774,7 +810,9 @@ public String getName() { * @return A list of GitHub releases, possibly empty. */ private static List getReleases() { - Path cachedReleases = Path.of(InstanSegPreferences.modelDirectoryProperty().get(), "releases.json"); + Path modelDir = getModelDirectory().orElse(null); + Path cachedReleases = modelDir == null ? null : modelDir.resolve("releases.json"); + String uString = "https://api.github.com/repos/instanseg/InstanSeg/releases"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uString)) @@ -789,15 +827,19 @@ private static List getReleases() { // if response is okay, then cache it if (response.statusCode() == 200) { json = response.body(); - JsonElement jsonElement = JsonParser.parseString(json); - Files.writeString(cachedReleases, gson.toJson(jsonElement)); + if (cachedReleases != null && Files.exists(cachedReleases.getParent())) { + JsonElement jsonElement = JsonParser.parseString(json); + Files.writeString(cachedReleases, gson.toJson(jsonElement)); + } else { + logger.debug("Unable to cache release information - no model directory specified"); + } } else { // otherwise problems throw new IOException("Unable to fetch GitHub release information, status " + response.statusCode()); } } catch (IOException | InterruptedException e) { // if not, try to fall back on a cached version - if (Files.exists(cachedReleases)) { + if (cachedReleases != null && Files.exists(cachedReleases)) { try { json = Files.readString(cachedReleases); } catch (IOException ex) { diff --git a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java index 4372ccc..2d08fe2 100644 --- a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java +++ b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java @@ -22,8 +22,6 @@ import qupath.lib.objects.hierarchy.events.PathObjectSelectionListener; import java.awt.image.BufferedImage; -import java.io.IOException; -import java.nio.file.Path; import java.util.Collection; import java.util.ResourceBundle; @@ -129,7 +127,8 @@ private String getWarningText() { return resources.getString("ui.error.no-selection"); if (deviceChoiceBox.getSelectionModel().isEmpty()) return resources.getString("ui.error.no-device"); - if (modelChoiceBox.getSelectionModel().getSelectedItem().isDownloaded(Path.of(InstanSegPreferences.modelDirectoryProperty().get()))) { + var modelDir = InstanSegController.getModelDirectory().orElse(null); + if (modelDir != null && modelChoiceBox.getSelectionModel().getSelectedItem().isDownloaded(modelDir)) { // shouldn't happen if downloaded anyway! var modelChannels = modelChoiceBox.getSelectionModel().getSelectedItem().getNumChannels(); if (modelChannels.isPresent()) { diff --git a/src/main/resources/qupath/ext/instanseg/ui/strings.properties b/src/main/resources/qupath/ext/instanseg/ui/strings.properties index 58b2e1c..ef13cba 100644 --- a/src/main/resources/qupath/ext/instanseg/ui/strings.properties +++ b/src/main/resources/qupath/ext/instanseg/ui/strings.properties @@ -67,6 +67,7 @@ ui.options.directory.tooltip = Choose the directory where models should be store # Model directories ui.model-directory.choose-directory = Choose directory +ui.model-directory.choose-prompt = Please choose a folder to store models ## Other Windows # Processing Window and progress pop-ups @@ -93,3 +94,4 @@ error.querying-local = Error querying local files error.localModel = Can't find file in user model directory error.tiles-failed = %d tiles failed. This is often a memory issue.\nConsider decreasing tile size or the number of threads used. error.modelPath = Unable to fetch model path + From cb6de7a0f6a2d46fb3fac1ff577ce6af16f5ea34 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 5 Sep 2024 06:44:27 +0100 Subject: [PATCH 14/15] Highlight when model directory not set --- .../ext/instanseg/ui/InstanSegController.java | 57 +++++++++++++++---- .../ext/instanseg/ui/MessageTextHelper.java | 4 +- .../ext/instanseg/ui/instanseg_control.fxml | 56 +++++++++++------- .../ext/instanseg/ui/strings.properties | 3 +- 4 files changed, 87 insertions(+), 33 deletions(-) diff --git a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java index 6b33f99..3a7246c 100644 --- a/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java +++ b/src/main/java/qupath/ext/instanseg/ui/InstanSegController.java @@ -7,6 +7,7 @@ import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; +import javafx.beans.binding.ObjectBinding; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -119,6 +120,8 @@ public class InstanSegController extends BorderPane { private CheckBox makeMeasurementsCheckBox; @FXML private Button infoButton; + @FXML + private Label modelDirLabel; private final ExecutorService pool = Executors.newSingleThreadExecutor(ThreadTools.createThreadFactory("instanseg", true)); private final QuPathGUI qupath; @@ -127,6 +130,19 @@ public class InstanSegController extends BorderPane { private final BooleanProperty needsUpdating = new SimpleBooleanProperty(); + private static final ObjectBinding modelDirectoryProperty = Bindings.createObjectBinding( + () -> tryToGetPath(InstanSegPreferences.modelDirectoryProperty().get()), + InstanSegPreferences.modelDirectoryProperty() + ); + + private final BooleanBinding isModelDirectoryValid = Bindings.createBooleanBinding( + () -> { + var path = modelDirectoryProperty.get(); + return path != null && Files.isDirectory(path); + }, + modelDirectoryProperty + ); + /** * Create an instance of the InstanSeg GUI pane. * @param qupath The QuPath GUI it should be attached to. @@ -147,13 +163,24 @@ private InstanSegController(QuPathGUI qupath) throws IOException { watcher = new Watcher(modelChoiceBox); configureMessageLabel(); + configureDirectoryLabel(); configureTileSizes(); configureDeviceChoices(); configureModelChoices(); configureSelectButtons(); configureRunning(); configureThreadSpinner(); - BooleanBinding currentModelIsDownloaded = Bindings.createBooleanBinding( + BooleanBinding currentModelIsDownloaded = createModelDownloadedBinding(); + infoButton.disableProperty().bind(currentModelIsDownloaded.not()); + downloadButton.disableProperty().bind( + currentModelIsDownloaded.or( + modelChoiceBox.getSelectionModel().selectedItemProperty().isNull()) + ); + configureChannelPicker(); + } + + private BooleanBinding createModelDownloadedBinding() { + return Bindings.createBooleanBinding( () -> { var model = modelChoiceBox.getSelectionModel().getSelectedItem(); if (model == null) { @@ -164,12 +191,6 @@ private InstanSegController(QuPathGUI qupath) throws IOException { }, modelChoiceBox.getSelectionModel().selectedItemProperty(), needsUpdating, InstanSegPreferences.modelDirectoryProperty()); - - infoButton.disableProperty().bind(currentModelIsDownloaded.not()); - downloadButton.disableProperty().bind( - currentModelIsDownloaded.or(modelChoiceBox.getSelectionModel().selectedItemProperty().isNull()) - ); - configureChannelPicker(); } @@ -374,8 +395,7 @@ private static Path tryToGetPath(String path) { } static Optional getModelDirectory() { - var path = InstanSegPreferences.modelDirectoryProperty().get(); - return Optional.ofNullable(tryToGetPath(path)); + return Optional.ofNullable(modelDirectoryProperty.get()); } private void configureModelChoices() { @@ -419,8 +439,8 @@ private void downloadModel() { try { var modelDir = getModelDirectory().orElse(null); if (modelDir == null || !Files.exists(modelDir)) { - logger.warn("Can't download model {}, model directory not found", - modelChoiceBox.getSelectionModel().getSelectedItem()); + Dialogs.showErrorMessage(resources.getString("title"), + resources.getString("ui.model-directory.choose-prompt")); return; } var model = modelChoiceBox.getSelectionModel().getSelectedItem(); @@ -552,6 +572,21 @@ private void configureMessageLabel() { }); } + private void configureDirectoryLabel() { + isModelDirectoryValid.addListener((v, o, n) -> updateModelDirectoryLabel()); + updateModelDirectoryLabel(); + } + + private void updateModelDirectoryLabel() { + if (isModelDirectoryValid.get()) { + modelDirLabel.getStyleClass().setAll("standard-message"); + modelDirLabel.setText(resources.getString("ui.options.directory")); + } else { + modelDirLabel.getStyleClass().setAll("warning-message"); + modelDirLabel.setText(resources.getString("ui.options.directory-not-set")); + } + } + static void addModelsFromPath(Path path, ComboBox box) { if (path == null || !Files.exists(path) || !Files.isDirectory(path)) return; // See https://github.com/controlsfx/controlsfx/issues/1320 diff --git a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java index 2d08fe2..af95f1e 100644 --- a/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java +++ b/src/main/java/qupath/ext/instanseg/ui/MessageTextHelper.java @@ -29,8 +29,10 @@ * Helper class for determining which text to display in the message label. */ class MessageTextHelper { + private static final ResourceBundle resources = ResourceBundle.getBundle("qupath.ext.instanseg.ui.strings"); - private static final QuPathGUI qupath = QuPathGUI.getInstance(); + + private final QuPathGUI qupath = QuPathGUI.getInstance(); private final SelectedObjectCounter selectedObjectCounter; private final SearchableComboBox modelChoiceBox; private final ChoiceBox deviceChoiceBox; diff --git a/src/main/resources/qupath/ext/instanseg/ui/instanseg_control.fxml b/src/main/resources/qupath/ext/instanseg/ui/instanseg_control.fxml index cd54945..05f84f0 100644 --- a/src/main/resources/qupath/ext/instanseg/ui/instanseg_control.fxml +++ b/src/main/resources/qupath/ext/instanseg/ui/instanseg_control.fxml @@ -1,16 +1,32 @@ - - - - - - + - + + + + + + + + + + + + + + + + + + + + + +
- + @@ -91,11 +107,11 @@ - + - + -