diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..2a01281 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,42 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: Java CI with Gradle + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 # TODO: check Java version + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b + - name: Build with Gradle + uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 + with: + arguments: build + - uses: actions/upload-artifact@v3 + with: + name: jar + path: build/libs + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8057f78 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Make draft release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Define version env variable + run: | + echo "VERSION=$(echo ${{ github.ref_name }} | sed -r 's/v([0-9]+\.[0-9]+\.?[0-9]?)/\1/'" >> $GITHUB_ENV + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v1.1.0 + - name: Build with Gradle + uses: gradle/gradle-build-action@v2.7.1 + with: + arguments: build -Pversion=$VERSION + - name: Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create --draft ${{ github.ref_name }} --title ${{ github.ref_name }} build/libs/* diff --git a/.gitignore b/.gitignore index 524f096..66258b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,41 @@ -# Compiled class file -*.class - -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* -replay_pid* +# Javadocs +docs/ + +# Maven +deploy/ +target/ +log/ + +# IntelliJ +.idea/ +*.iml +out/ + +# Gradle +# Use local properties (e.g. to set a specific JDK) +gradle.properties +build/ +.gradle/ +gradle.properties + +# Eclipse +.settings/ +.project +.classpath + +# VSCode +.vscode/ + +# Mac +.DS_Store + +# Java +hs_err*.log + +# Other +*.tmp +*.bak +*.swp +*~.nib +*thumbs.db +bin/ diff --git a/README.md b/README.md index 2ec3405..41ee83c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,115 @@ -# basic-stitching -Based on QuPath extension template - for basic tiling with no overlap resolution +# QuPath extension template + +This repo contains a template and instructions to help create a new extension for [QuPath](https://qupath.github.io). + +It already contains two minimal extensions, so the first task is to make sure that they work. +Then, it's a matter of customizing the code to make it more useful. + +> There are two extensions to show that you can use either Java or Groovy. + +## Build the extension + +Building the extension with Gradle should be pretty easy - you don't even need to install Gradle separately, because the +[Gradle Wrapper](https://docs.gradle.org/current/userguide/gradle_wrapper.html) will take care of that. + +Open a command prompt, navigate to where the code lives, and use +```bash +gradlew build +``` + +The built extension should be found inside `build/libs`. +You can drag this onto QuPath to install it. +You'll be prompted to create a user directory if you don't already have one. + +The minimal extension here doesn't do much, but it should at least install a new command under the 'Extensions' menu in +QuPath. + +> In case your extension contains external dependencies beyond what QuPath already includes, you can create a +> [single jar file](https://imperceptiblethoughts.com/shadow/introduction/#benefits-of-shadow) that bundles these along +> with your extension by using +> ```bash +> gradlew shadowJar +> ``` +> If you don't do that, you'll need to drag *all* the extra dependences onto QuPath to install them as well. + + +## Set up in an IDE (optional) + +During development, things are likely to be much easier if you work within an IDE. + +QuPath itself is developed using IntelliJ, and you can import the extension template there. + +However, for development and testing, it can help to import QuPath *and* the extension and have them in your IDE side-by-side. + +In IntelliJ, you can do this in a few steps: +* Get QuPath's source code, as described at https://qupath.readthedocs.io/en/0.4/docs/reference/building.html +* Store your extension code in a directory *beside* QuPath's code. So it should be located next to the `qupath` code directory. +* Import QuPath into IntelliJ as a Gradle project (you don't need to import the extension yet!) + * See https://www.jetbrains.com/help/idea/work-with-gradle-projects.html +* Within `qupath/settings.gradle` add the line `includeFlat 'your-extension-code-directory'` (updating the code directory as needed) +* Refresh the Gradle project in IntelliJ, and your extension code should appear +* Create a [Run configuration](https://www.jetbrains.com/help/idea/run-debug-configuration.html) in IntelliJ to launch QuPath. An example of how that looks is shown below: + +QuPath run configuration in IntelliJ + +Now when you run QuPath from IntelliJ, your extension should (hopefully) be found - there's no need to add it by drag & drop. + +## Customize the extension + +There are a few fixed steps to customizing the extension, and then the main creative part where you add your own code. + +### Update `settings.gradle` + +Open `settings.gradle` and check the comment lines flagged with `\\TODO`. +These point you towards parts you may well need to change. + +### Update `build.gradle` + +Open `build.gradle` and follow a similar process to with `settings.gradle`, to update the bits flagged with `\\TODO`. + +### Create the extension Java or Groovy file(s) + +For the extension to work, you need to create at least one file that extends `qupath.lib.gui.extensions.QuPathExtension`. + +There are two examples in the template, in two languages: +* **Java:** `qupath.ext.template.DemoExtension.java`. +* **Groovy:** `qupath.ext.template.DemoGroovyExtension.java`. + +You can pick the one that corresponds to the language you want to use, and delete the other. + +Then take your chosen file and rename it, edit it, move it to another package... basically, make it your own. + +> Please **don't neglect this step!** +> If you do, there's a chance of multiple extensions being created with the same class names... and causing confusion later. + +### Update the `META-INF/services` file + +For QuPath to *find* the extension later, the full class name needs to be available in `resources/META-INFO/services/qupath.lib.gui.extensions.QuPathExtensions`. + +So remember to edit that file to include the class name that you actually used for your extension. + +### Specify your license + +Add a license file to your GitHub repo so that others know what they can and can't do with your extension. + +This should be compatible with QuPath's license -- see https://github.com/qupath/qupath + +### Replace this readme + +Don't forget to replace the contents of this readme with your own! + + +## Getting help + +For questions about QuPath and/or creating new extensions, please use the forum at https://forum.image.sc/tag/qupath + +------ + +## License + +This is just a template, you're free to use it however you like. +You can treat the contents of *this repository only* as being under [the Unlicense](https://unlicense.org) (except for the Gradle wrapper, which has its own license included). + +If you use it to create a new QuPath extension, I'd strongly encourage you to select a suitable open-source license for the extension. + +Note that *QuPath itself* is available under the GPL, so you do have to abide by those terms: see https://github.com/qupath/qupath for more. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d1edea2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,172 @@ +plugins { + // Main gradle plugin for building a Java library + id 'java-library' + // Support writing the extension in Groovy (remove this if you don't want to) + id 'groovy' + // To create a shadow/fat jar that bundle up all dependencies + id 'com.github.johnrengelman.shadow' version '8.1.1' + // Include this plugin to avoid downloading JavaCPP dependencies for all platforms + id 'org.bytedeco.gradle-javacpp-platform' + id 'org.openjfx.javafxplugin' version '0.1.0' + id 'maven-publish' +} + + +ext.moduleName = 'qupath.extension.basic-stitching' +group = 'qupath.ext.basicstitching' + +base { + archivesName = rootProject.name + version = '0.2.0' + description = 'Provides basic tiling, no overlap resolution yet.' +} + + +// The default 'gradle.ext.qupathVersion' reads this from settings.gradle. +ext.qupathVersion = gradle.ext.qupathVersion + + +// Should be Java 17 for QuPath v0.5.0 +ext.qupathJavaVersion = 17 + +/** + * Define dependencies. + * - Using 'shadow' indicates that they are already part of QuPath, so you don't need + * to include them in your extension. If creating a single 'shadow jar' containing your + * extension and all dependencies, these won't be added. + * - Using 'implementation' indicates that you need the dependency for the extension to work, + * and it isn't part of QuPath already. If you are creating a single 'shadow jar', the + * dependency should be bundled up in the extension. + * - Using 'testImplementation' indicates that the dependency is only needed for testing, + * but shouldn't be bundled up for use in the extension. + */ +dependencies { + + // Main QuPath user interface jar. + // Automatically includes other QuPath jars as subdependencies. + shadow "io.github.qupath:qupath-gui-fx:${qupathVersion}" + + // For logging - the version comes from QuPath's version catalog at + // https://github.com/qupath/qupath/blob/main/gradle/libs.versions.toml + // See https://docs.gradle.org/current/userguide/platforms.html + shadow libs.slf4j + + // If you aren't using Groovy, this can be removed + shadow libs.bundles.groovy + + testImplementation "io.github.qupath:qupath-gui-fx:${qupathVersion}" + testImplementation libs.junit + implementation "io.github.qupath:qupath-extension-bioformats:${qupathVersion}" +} + +/* + * Manifest info + */ +jar { + manifest { + attributes("Implementation-Title": project.name, + "Implementation-Version": archiveVersion, + "Automatic-Module-Name": moduleName) + } +} + +/** + * Copy necessary attributes, see + * - https://github.com/qupath/qupath-extension-template/issues/9 + * - https://github.com/openjfx/javafx-gradle-plugin#variants + */ +configurations.shadow { + def runtimeAttributes = configurations.runtimeClasspath.attributes + runtimeAttributes.keySet().each { key -> + if (key in [Usage.USAGE_ATTRIBUTE, OperatingSystemFamily.OPERATING_SYSTEM_ATTRIBUTE, MachineArchitecture.ARCHITECTURE_ATTRIBUTE]) + attributes.attribute(key, runtimeAttributes.getAttribute(key)) + } +} + +/* + * Copy the LICENSE file into the jar... if we have one (we should!) + */ +processResources { + from ("${projectDir}/LICENSE") { + into 'licenses/' + } +} + +/* + * Define extra 'copyDependencies' task to copy dependencies into the build directory. + */ +tasks.register("copyDependencies", Copy) { + description "Copy dependencies into the build directory for use elsewhere" + group "QuPath" + + from configurations.default + into 'build/libs' +} + +/* + * Ensure Java 17 compatibility, and include sources and javadocs when building. + */ +java { + toolchain { + languageVersion = JavaLanguageVersion.of(qupathJavaVersion) + } + withSourcesJar() + withJavadocJar() +} + +/* + * Create javadocs for all modules/packages in one place. + * Use -PstrictJavadoc=true to fail on error with doclint (which is rather strict). + */ +tasks.withType(Javadoc) { + options.encoding = 'UTF-8' + def strictJavadoc = findProperty('strictJavadoc') + if (!strictJavadoc) { + options.addStringOption('Xdoclint:none', '-quiet') + } +} + +/* + * Specify that the encoding should be UTF-8 for source files + */ +tasks.named('compileJava') { + options.encoding = 'UTF-8' +} + +/* + * Avoid 'Entry .gitkeep is a duplicate but no duplicate handling strategy has been set.' + * when using withSourcesJar() + */ +tasks.withType(org.gradle.jvm.tasks.Jar) { + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} + +/* + * Support tests with JUnit. + */ +tasks.named('test') { + useJUnitPlatform() +} + +// Looks redundant to include this here and in settings.gradle, +// but helps overcome some gradle trouble when including this as a subproject +// within QuPath itself (which is useful during development). +repositories { + // Add this if you need access to dependencies only installed locally + // mavenLocal() + + mavenCentral() + + // Add scijava - which is where QuPath's jars are hosted + maven { + url "https://maven.scijava.org/content/groups/public" + } +} + +publishing { + publications { + myLibrary(MavenPublication) { + from components.java + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a595206 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..a69d9cb --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f127cfd --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/qupath-intellij.png b/qupath-intellij.png new file mode 100644 index 0000000..bcbd526 Binary files /dev/null and b/qupath-intellij.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..c439a40 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,45 @@ +pluginManagement { + plugins { + // Gradle is awkward about declaring versions for plugins + // Specifying it here, rather than build.gradle, makes it possible + // to include the extension as a subproject of QuPath itself + // (which is useful during development) + id 'org.bytedeco.gradle-javacpp-platform' version '1.5.9' + } +} + + +rootProject.name = 'basic-stitching' + + +// Note that the QuPath API isn't stable; something designed for +// 0.X.a should work with 0.X.b, but not necessarily with 0.Y.a. +gradle.ext.qupathVersion = "0.5.0" + +dependencyResolutionManagement { + + // Access QuPath's version catalog for dependency versions + versionCatalogs { + libs { + from("io.github.qupath:qupath-catalog:${gradle.ext.qupathVersion}") + } + } + + repositories { + + mavenCentral() + + // Add scijava - which is where QuPath's jars are + maven { + url "https://maven.scijava.org/content/groups/public" + } + maven { + url "https://maven.scijava.org/content/repositories/releases" + } + + maven { + url "https://maven.scijava.org/content/repositories/snapshots" + } + + } +} diff --git a/src/main/groovy/qupath/ext/basicstitching/BasicStitchingExtension.groovy b/src/main/groovy/qupath/ext/basicstitching/BasicStitchingExtension.groovy new file mode 100644 index 0000000..113ee55 --- /dev/null +++ b/src/main/groovy/qupath/ext/basicstitching/BasicStitchingExtension.groovy @@ -0,0 +1,61 @@ +package qupath.ext.basicstitching + +import javafx.scene.control.MenuItem +import qupath.ext.basicstitching.functions.StitchingGUI +import qupath.lib.common.Version +import qupath.lib.gui.QuPathGUI +import qupath.lib.gui.extensions.QuPathExtension + +/** + TODO: create public functions so that stitching can be run from command line or a script + CHECK: Always build AND publish to maven local for use with qp-scope + ./gradlew publishToMavenLocal + + */ +class BasicStitchingExtension implements QuPathExtension { + + // Setting the variables here is enough for them to be available in the extension + String name = "Basic stitching" + String description = "Basic stitching extension that puts tiles together into pyramidal ome.tif files, no overlap resolution or flat field correction." + Version QuPathVersion = Version.parse("v0.5.0") + +// @Override +// void installExtension(QuPathGUI qupath) { +// qupath.installActions(ActionTools.getAnnotatedActions(new BSCommands(qupath))) +// addMenuItem(qupath) +// } + @Override + void installExtension(QuPathGUI qupath) { + addMenuItem(qupath) + } + /** + * Get the description of the extension. + * + * @return The description of the extension. + */ + @Override + public String getDescription() { + return "Stitch tiles into a pyramidal ome.tif"; + } + + /** + * Get the name of the extension. + * + * @return The name of the extension. + */ + @Override + public String getName() { + return "BasicStitching"; + } + + private void addMenuItem(QuPathGUI qupath) { + def menu = qupath.getMenu("Extensions>${name}", true) + def fileNameStitching = new MenuItem("Basic Stitching Extension") + fileNameStitching.setOnAction(e -> { + StitchingGUI.createGUI() + + }) + menu.getItems() << fileNameStitching + } + +} diff --git a/src/main/groovy/qupath/ext/basicstitching/functions/StitchingGUI.groovy b/src/main/groovy/qupath/ext/basicstitching/functions/StitchingGUI.groovy new file mode 100644 index 0000000..bd728b0 --- /dev/null +++ b/src/main/groovy/qupath/ext/basicstitching/functions/StitchingGUI.groovy @@ -0,0 +1,405 @@ +// TODO Progress bar for stitching +// TODO Estimate size of stitched image to predict necessary memory +// Warn user if size exceeds QuPath's allowed limits. + +package qupath.ext.basicstitching.functions + +import javafx.scene.Node +import javafx.scene.control.* +import javafx.scene.layout.GridPane +import javafx.stage.DirectoryChooser +import javafx.stage.Modality +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import qupath.ext.basicstitching.stitching.StitchingImplementations +import qupath.lib.gui.scripting.QPEx + +import java.awt.* + +import static qupath.ext.basicstitching.utilities.UtilityFunctions.getCompressionTypeList + +class StitchingGUI { + + private static final Logger logger = LoggerFactory.getLogger(StitchingGUI.class); + static TextField folderField = new TextField(); + static ComboBox compressionBox = new ComboBox<>(); + static TextField pixelSizeField = new TextField("0.4988466"); + static TextField downsampleField = new TextField("1"); + static TextField matchStringField = new TextField("20x"); + static ComboBox stitchingGridBox = new ComboBox<>(); // New combo box for stitching grid options + static Button folderButton = new Button("Select Folder"); + // Declare labels as static fields + static Label stitchingGridLabel = new Label("Stitching Method:"); + static Label folderLabel = new Label("Folder location:"); + static Label compressionLabel = new Label("Compression type:"); + static Label pixelSizeLabel = new Label("Pixel size, microns:"); + static Label downsampleLabel = new Label("Downsample:"); + static Label matchStringLabel = new Label("Stitch sub-folders with text string:"); + static Hyperlink githubLink = new Hyperlink("GitHub ReadMe"); + + // Map to hold the positions of each GUI element + private static Map guiElementPositions = new HashMap<>(); + + + static void createGUI() { + // Create the dialog + def dlg = new Dialog() + dlg.initModality(Modality.APPLICATION_MODAL) + dlg.setTitle("Input Stitching Method and Options") + dlg.setHeaderText("Enter your settings below:") + + // Set the content + dlg.getDialogPane().setContent(createContent()) + + // Add Okay and Cancel buttons + dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL) + + // Show the dialog and capture the response + def result = dlg.showAndWait() + + // Handling the response + if (result.isPresent() && result.get() == ButtonType.OK) { + String folderPath = folderField.getText() // Assuming folderField is accessible + String compressionType = compressionBox.getValue() // Assuming compressionBox is accessible + + // Check if pixelSizeField and downsampleField are not empty + double pixelSize = pixelSizeField.getText() ? Double.parseDouble(pixelSizeField.getText()) : 0 + // Default to 0 if empty + double downsample = downsampleField.getText() ? Double.parseDouble(downsampleField.getText()) : 1 + // Default to 1 if empty + + String matchingString = matchStringField.getText() // Assuming matchStringField is accessible + String stitchingType = stitchingGridBox.getValue() + // Call the function with collected data + String finalImageName = StitchingImplementations.stitchCore(stitchingType, folderPath, folderPath, compressionType, pixelSize, downsample, matchingString) + //stitchByFileName(folderPath, compressionType, pixelSize, downsample, matchingString) + } + + } + +/** + * Creates and returns a GridPane containing all the components for the GUI. + * This method initializes the positions of each component in the grid, + * adds the components to the grid, and sets their initial visibility. + * + * @return A GridPane containing all the configured components. + */ + private static GridPane createContent() { + // Create a new GridPane for layout + GridPane pane = new GridPane(); + + // Set horizontal and vertical gaps between grid cells + pane.setHgap(10); + pane.setVgap(10); + + // Initialize the positions of each component in the grid + initializePositions(); + + // Add various components to the grid pane + // Each method call below corresponds to a specific component or a group of components + addStitchingGridComponents(pane); // Adds a combo box for selecting the stitching method + addFolderSelectionComponents(pane); // Adds components for selecting the folder location + addMatchStringComponents(pane); // Adds a text field for entering the matching string + addCompressionComponents(pane); // Adds a combo box for selecting the compression type + addPixelSizeComponents(pane); // Adds a text field for entering the pixel size + addDownsampleComponents(pane); // Adds a text field for entering the downsample value + + // Add a hyperlink to the GitHub repository at the bottom of the pane + addGitHubLinkComponent(pane); + + // Update the components' visibility based on the current selection in the stitching method combo box + updateComponentsBasedOnSelection(pane); + + // Return the fully configured GridPane + return pane; + } + + +/** + * Adds a label and its associated control to the specified GridPane. + * The method uses the guiElementPositions map to determine the correct + * row index for the label and control in the grid. If the row index is + * not found, an error is logged. + * + * @param pane The GridPane to which the label and control are added. + * @param label The label to be added to the grid. + * @param control The control (e.g., TextField, ComboBox) associated with the label. + */ + private static void addToGrid(GridPane pane, Node label, Node control) { + // Retrieve the row index for the label from the guiElementPositions map + Integer rowIndex = guiElementPositions.get(label); + + // Check if the row index was found + if (rowIndex != null) { + // Add the label and control to the grid at the specified row index + pane.add(label, 0, rowIndex); // Add label to column 0 + pane.add(control, 1, rowIndex); // Add control to column 1 + } else { + // Log an error if the row index is not found + logger.error("Row index not found for component: " + label); + } + } + +/** + * Adds a GitHub repository hyperlink to the GridPane. The hyperlink is configured to open + * the GitHub page in the default web browser when clicked. The position of the hyperlink in the + * GridPane is determined based on its predefined row index in the guiElementPositions map. + * + * @param pane The GridPane to which the GitHub hyperlink is to be added. + */ + private static void addGitHubLinkComponent(GridPane pane) { + // Set up the action on the hyperlink to open the GitHub repository URL + // in the user's default web browser when clicked. + githubLink.setOnAction(e -> { + try { + // Open the GitHub repository URL + Desktop.getDesktop().browse(new URI("https://github.com/MichaelSNelson/BasicStitching")); + } catch (Exception ex) { + // Log any error encountered while trying to open the URL + logger.error("Error opening link", ex); + } + }); + + // Retrieve the pre-defined row index for the hyperlink from the guiElementPositions map. + // This determines where the hyperlink will be placed in the GridPane. + Integer rowIndex = guiElementPositions.get(githubLink); + + // Add the hyperlink to the specified row in the GridPane, spanning across 2 columns. + pane.add(githubLink, 0, rowIndex, 2, 1); + } + + +/** + * Initializes the positions of GUI elements in the GridPane. This method assigns a unique + * row index to each GUI element by incrementally increasing a position counter. The positions + * are stored in the guiElementPositions map, which maps each GUI element (Node) to its row index + * in the GridPane. + */ + private static void initializePositions() { + // Start with a position counter at 0. + int currentPosition = 0; + + // Dynamically assign row positions to each GUI element. + // The order of these statements dictates their vertical order in the GridPane. + + guiElementPositions.put(stitchingGridLabel, currentPosition++); + // Position for stitching grid label and combo box + guiElementPositions.put(folderLabel, currentPosition++); // Position for folder label and text field + guiElementPositions.put(compressionLabel, currentPosition++); // Position for compression label and combo box + guiElementPositions.put(pixelSizeLabel, currentPosition++); // Position for pixel size label and text field + guiElementPositions.put(downsampleLabel, currentPosition++); // Position for downsample label and text field + guiElementPositions.put(matchStringLabel, currentPosition++); + // Position for matching string label and text field + guiElementPositions.put(githubLink, currentPosition++); // Position for the GitHub hyperlink + + // More components can be added here following the same pattern. + } + + +/** + * Adds stitching grid components to the specified GridPane. This method configures a combo box + * for selecting the stitching method and adds it along with its label to the GridPane. + * It clears any existing items in the combo box and adds a predefined set of stitching options. + * The default stitching method is set, and an action is defined to update component visibility + * based on the selected stitching method. + * + * @param pane The GridPane to which the stitching grid components are to be added. + */ + private static void addStitchingGridComponents(GridPane pane) { + // Clear any existing items in the combo box to avoid duplicates + stitchingGridBox.getItems().clear(); + + // Add a set of predefined stitching options to the combo box + stitchingGridBox.getItems().addAll( + "Vectra tiles with metadata", + "Filename[x,y] with coordinates in microns", + "Coordinates in TileConfiguration.txt file" + ); + + // Set the default value for the combo box + stitchingGridBox.setValue("Coordinates in TileConfiguration.txt file"); + + // Define an action to be performed when a new item is selected in the combo box + // This action updates the visibility of other components based on the selection + stitchingGridBox.setOnAction(e -> updateComponentsBasedOnSelection(pane)); + + // Add the stitching method label and the combo box to the GridPane + // using the addToGrid helper method + addToGrid(pane, stitchingGridLabel as Node, stitchingGridBox as Node); + } + + +/** + * Adds components for folder selection to the specified GridPane. This method configures a text field + * for displaying the selected folder path and a button to open a directory chooser dialog. + * It attempts to set a default folder path and defines the action for the button to select a folder. + * The text field and the button are added to the GridPane in their designated positions. + * + * @param pane The GridPane to which the folder selection components are to be added. + */ + private static void addFolderSelectionComponents(GridPane pane) { + // Attempt to initialize the folder path text field with a default path + try { + String defaultFolderPath = QPEx.buildPathInProject("Tiles"); + logger.info("Default folder path: {}", defaultFolderPath); + folderField.setText(defaultFolderPath); + } catch (Exception e) { + // If the default path cannot be set, probably due to no project being open + logger.info("Error setting default folder path, usually due to no project being open", e); + } + + // Configure the action for the folder selection button + folderButton.setOnAction(e -> { + try { + DirectoryChooser dirChooser = new DirectoryChooser(); + dirChooser.setTitle("Select Folder"); + + // Get the initial directory path from the text field + String initialDirPath = folderField.getText(); + File initialDir = new File(initialDirPath); + + // Set the initial directory in the directory chooser if it exists + if (initialDir.exists() && initialDir.isDirectory()) { + dirChooser.setInitialDirectory(initialDir); + } else { + logger.warn("Initial directory does not exist or is not a directory: {}", initialDir.getAbsolutePath()); + } + + // Show the directory chooser dialog and update the text field with the selected directory + File selectedDir = dirChooser.showDialog(null); // Replace null with your stage if available + if (selectedDir != null) { + folderField.setText(selectedDir.getAbsolutePath()); + logger.info("Selected folder path: {}", selectedDir.getAbsolutePath()); + } + } catch (Exception ex) { + // Log an error if there is an issue during folder selection + logger.error("Error selecting folder", ex); + } + }); + + // Add the folder label and text field to the GridPane + addToGrid(pane, folderLabel as Node, folderField as Node); + + // Retrieve the row index for placing the folder button and add it to the GridPane + Integer rowIndex = guiElementPositions.get(folderLabel); + if (rowIndex != null) { + pane.add(folderButton, 2, rowIndex); // Place the button next to the text field + } else { + // Log an error if the row index for the folder button is not found + logger.error("Row index not found for folderButton"); + } + } + +//TODO populate with a list of compression types +/** + * Adds compression selection components to the specified GridPane. + * This method configures a combo box with options for different types of image compression + * and adds it to the grid along with a label. A tooltip is also set for the label and + * the combo box to provide additional information to the user. + * + * @param pane The GridPane to which the compression components are to be added. + */ + private static void addCompressionComponents(GridPane pane) { + // Clear any existing items and add new compression options to the combo box + def compressionTypes = getCompressionTypeList() + compressionBox.getItems().clear(); + compressionBox.getItems().addAll(compressionTypes); + + // Set the default value for the combo box + compressionBox.setValue("J2K_LOSSY"); + + // Create and set a tooltip for additional information + Tooltip compressionTooltip = new Tooltip("Select the type of image compression."); + compressionLabel.setTooltip(compressionTooltip); + compressionBox.setTooltip(compressionTooltip); + + // Add the compression label and combo box to the GridPane + addToGrid(pane, compressionLabel as Node, compressionBox as Node); + } + + +/** + * Adds pixel size input components to the specified GridPane. + * This method adds a label and a text field for entering the pixel size to the grid. + * + * @param pane The GridPane to which the pixel size components are to be added. + */ + private static void addPixelSizeComponents(GridPane pane) { + // Add the pixel size label and text field to the GridPane + addToGrid(pane, pixelSizeLabel as Node, pixelSizeField as Node); + } + + +/** + * Adds downsample input components to the specified GridPane. + * This method adds a label and a text field for entering the downsample value to the grid. + * A tooltip is also set for the label and the text field to provide additional information. + * + * @param pane The GridPane to which the downsample components are to be added. + */ + private static void addDownsampleComponents(GridPane pane) { + // Create and set a tooltip for additional information + Tooltip downsampleTooltip = new Tooltip("The amount by which the highest resolution plane will be initially downsampled."); + downsampleLabel.setTooltip(downsampleTooltip); + downsampleField.setTooltip(downsampleTooltip); + + // Add the downsample label and text field to the GridPane + addToGrid(pane, downsampleLabel as Node, downsampleField as Node); + } + + +/** + * Adds matching string input components to the specified GridPane. + * This method adds a label and a text field for entering the matching string to the grid. + * + * @param pane The GridPane to which the matching string components are to be added. + */ + private static void addMatchStringComponents(GridPane pane) { + // Add the matching string label and text field to the GridPane + addToGrid(pane, matchStringLabel as Node, matchStringField as Node); + } + + +/** + * Updates the visibility of certain GUI components based on the current selection + * in the stitching method combo box. Specifically, it hides or shows the pixel size + * field and label based on the selected stitching method. + * + * @param pane The GridPane containing the components to be updated. + */ + private static void updateComponentsBasedOnSelection(GridPane pane) { + // Determine whether to hide the pixel size field and label + boolean hidePixelSize = stitchingGridBox.getValue().equals("Vectra multiplex tif") || + stitchingGridBox.getValue().equals("Coordinates in TileCoordinates.txt file"); + pixelSizeLabel.setVisible(!hidePixelSize); + pixelSizeField.setVisible(!hidePixelSize); + + // Adjust the layout of the GridPane to reflect the visibility changes + adjustLayout(pane); + } + + +/** + * Adjusts the layout of the GridPane based on the current positions specified in the guiElementPositions map. + * This method iterates through each GUI element in the map and updates its row index in the GridPane. + * The adjustment ensures that the GUI elements are displayed in the correct order and position, + * especially after any visibility changes. + * + * @param pane The GridPane whose layout is to be adjusted. + */ + private static void adjustLayout(GridPane pane) { + // Iterate through each entry in the guiElementPositions map + for (Map.Entry entry : guiElementPositions.entrySet()) { + Node node = entry.getKey(); // The GUI element (Node) + Integer newRow = entry.getValue(); // The new row index for the element + + // Check if the node is a part of the GridPane's children + if (pane.getChildren().contains(node)) { + // Update the node's row index in the GridPane + GridPane.setRowIndex(node, newRow); + } + } + } + + +} diff --git a/src/main/groovy/qupath/ext/basicstitching/stitching/StitchingImplementations.groovy b/src/main/groovy/qupath/ext/basicstitching/stitching/StitchingImplementations.groovy new file mode 100644 index 0000000..559ac74 --- /dev/null +++ b/src/main/groovy/qupath/ext/basicstitching/stitching/StitchingImplementations.groovy @@ -0,0 +1,650 @@ +package qupath.ext.basicstitching.stitching + +import org.slf4j.LoggerFactory +import qupath.ext.basicstitching.utilities.UtilityFunctions +import qupath.lib.common.GeneralTools +import qupath.lib.gui.QuPathGUI +import qupath.lib.gui.dialogs.Dialogs +import qupath.lib.images.servers.ImageServerProvider +import qupath.lib.images.servers.ImageServers +import qupath.lib.images.servers.SparseImageServer +import qupath.lib.images.writers.ome.OMEPyramidWriter +import qupath.lib.regions.ImageRegion + +import javax.imageio.ImageIO +import javax.imageio.plugins.tiff.BaselineTIFFTagSet +import javax.imageio.plugins.tiff.TIFFDirectory +import java.awt.image.BufferedImage +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +import static qupath.lib.scripting.QP.getLogger; + +//TODO Is there a way to apply the downsample when accessing the image regions so the full res image region doesn't need to be stored in memory? +// Maybe downsample after acquiring the region as a second step? + +// Interface for stitching strategies +interface StitchingStrategy { + List prepareStitching(String folderPath, double pixelSizeInMicrons, double baseDownsample, String matchingString) +} + +//TODO Look into reducing repetitive code here, though maybe the overall structure is wrong? +//abstract class BaseStitchingStrategy implements StitchingStrategy { +// protected List prepareStitchingCommon(String folderPath, double pixelSizeInMicrons, double baseDownsample, String matchingString, Closure processSubDirectory) { +// def logger = LoggerFactory.getLogger(QuPathGUI.class) +// Path rootdir = Paths.get(folderPath) +// List allFileRegionMaps = [] +// +// Files.newDirectoryStream(rootdir).each { path -> +// if (Files.isDirectory(path) && path.fileName.toString().contains(matchingString)) { +// def fileRegionMaps = processSubDirectory.call(path) +// allFileRegionMaps += fileRegionMaps +// } +// } +// +// if (allFileRegionMaps.isEmpty()) { +// Dialogs.showWarningNotification("Warning", "No valid tile configurations found in any subdirectory.") +// return [] +// } +// +// allFileRegionMaps +// } +//} +// Concrete strategy for stitching based on file names + +/** + * Strategy for stitching images based on file names. + * This class implements the {@link StitchingStrategy} interface and provides a specific + * algorithm for stitching based on the naming convention of image files. + */ +class FileNameStitchingStrategy implements StitchingStrategy { + + /** + * Prepares stitching by processing each subdirectory within the specified root directory. + * This method iterates over each subdirectory that matches the given criteria and + * aggregates file-region mapping information for stitching. + * + * @param folderPath The path to the root directory containing image files. + * @param pixelSizeInMicrons The pixel size in microns, used for calculating image regions. + * @param baseDownsample The base downsample value for the stitching process. + * @param matchingString A string to match for selecting relevant subdirectories. + * @return A list of maps, each map containing file, region, and subdirName information for stitching. + */ + @Override + List prepareStitching(String folderPath, double pixelSizeInMicrons, double baseDownsample, String matchingString) { + Path rootdir = Paths.get(folderPath) + List allFileRegionMaps = [] + + // Iterate over each subdirectory in the root directory + Files.newDirectoryStream(rootdir).each { path -> + if (Files.isDirectory(path) && path.fileName.toString().contains(matchingString)) { + // Process each subdirectory and collect file-region mappings + def fileRegionMaps = processSubDirectory(path, pixelSizeInMicrons, baseDownsample) + allFileRegionMaps += fileRegionMaps + } + } + + // Check if any valid file-region mappings were found + if (allFileRegionMaps.isEmpty()) { + Dialogs.showWarningNotification("Warning", "No valid tile configurations found in any subdirectory.") + return + } + + allFileRegionMaps + } + + /** + * Processes a single subdirectory to generate file-region mappings for stitching. + * This method collects all TIFF files in the directory, builds tile configurations, + * and creates a mapping of each file to its corresponding image region. + * + * @param dir The path to the subdirectory to be processed. + * @param pixelSizeInMicrons The pixel size in microns for calculating image regions. + * @param baseDownsample The base downsample value for the stitching process. + * @return A list of maps, each map containing file, region, and subdirName for stitching. + */ + private static List processSubDirectory(Path dir, double pixelSizeInMicrons, double baseDownsample) { + def logger = LoggerFactory.getLogger(QuPathGUI.class) + logger.info("Processing slide in folder $dir") + + // Collect all TIFF files in the directory + def files = [] + Files.newDirectoryStream(dir, "*.tif*").each { path -> + files << path.toFile() + } + + // Build tile configurations from the collected files + def tileConfigOutput = buildTileConfigWithMinCoordinates(dir) + def tileConfig = tileConfigOutput[0] + def minimumXY = tileConfigOutput[1] + + // Create file-region mappings for each file + List fileRegionMaps = [] + files.each { file -> + def region = parseRegionFromOffsetTileConfig(file as File, tileConfig as List, minimumXY, pixelSizeInMicrons) + if (region) { + // Add file, region, and subdir name to the mappings + fileRegionMaps << [file: file, region: region, subdirName: dir.getFileName().toString()] + } + } + + return fileRegionMaps + } + +/** + * Parses an image region from offset tile configuration. + * This method calculates the region of an image file based on its position within a larger stitched image. + * + * @param file The image file for which to parse the region. + * @param tileConfig A list of maps containing tile configurations, each with image name and its coordinates. + * @param minimumXY The minimum x and y coordinates among all tiles, used for offset calculations. + * @param pixelSizeInMicrons The size of a pixel in microns, used for scaling coordinates. + * @param z The z-plane index of the image (default is 0). + * @param t The timepoint index of the image (default is 0). + * @return An ImageRegion object representing the specified region of the image or null if not found. + */ + static ImageRegion parseRegionFromOffsetTileConfig(File file, List tileConfig, minimumXY, double pixelSizeInMicrons, int z = 0, int t = 0) { + String imageName = file.getName() + def config = tileConfig.find { it.imageName == imageName } + def logger = LoggerFactory.getLogger(QuPathGUI.class) + + if (config) { + int x = (config.x - minimumXY[0]) / pixelSizeInMicrons as int + int y = (config.y - minimumXY[1]) / pixelSizeInMicrons as int + def dimensions = UtilityFunctions.getTiffDimensions(file) + if (dimensions == null) { + logger.info("Could not retrieve dimensions for image $imageName") + return null + } + int width = dimensions.width + int height = dimensions.height + return ImageRegion.createInstance(x, y, width, height, z, t) + } else { + logger.info("No configuration found for image $imageName") + return null + } + } + +/** + * Builds tile configurations with minimum coordinates for a given directory. + * This method scans a directory for image files and extracts their coordinates from the file names. + * It then calculates the minimum X and Y coordinates among all images. + * + * @param dir The directory path containing the image files. + * @return A list containing two elements: a list of image configurations and an array [minX, minY]. + */ + static def buildTileConfigWithMinCoordinates(Path dir) { + def images = [] + def logger = LoggerFactory.getLogger(QuPathGUI.class) + Files.newDirectoryStream(dir, "*.{tif,tiff,ome.tif}").each { path -> + def matcher = path.fileName.toString() =~ /.*\[(\d+),(\d+)\].*\.(tif|tiff|ome.tif)$/ + if (matcher.matches()) { + def imageName = path.getFileName().toString() + int x = Integer.parseInt(matcher[0][1]) + int y = Integer.parseInt(matcher[0][2]) + images << ['imageName': imageName, 'x': x, 'y': y] + } + } + + def minX = images.min { it.x }?.x ?: 0 + def minY = images.min { it.y }?.y ?: 0 + return [images, [minX, minY]] + } +} + + +/** + * Strategy for stitching images based on tile configurations specified in a TileConfiguration.txt file. + * This class implements the {@link StitchingStrategy} interface, providing an algorithm + * for processing and stitching images based on their defined tile configurations. + */ +class TileConfigurationTxtStrategy implements StitchingStrategy { + + /** + * Prepares stitching by processing each subdirectory within the specified root directory. + * This method iterates over each subdirectory that matches the given criteria and + * aggregates file-region mapping information for stitching. + * + * @param folderPath The path to the root directory containing image files. + * @param pixelSizeInMicrons The pixel size in microns, used for calculating image regions. + * @param baseDownsample The base downsample value for the stitching process. + * @param matchingString A string to match for selecting relevant subdirectories. + * @return A list of maps, each map containing file, region, and subdirName information for stitching. + */ + @Override + List prepareStitching(String folderPath, double pixelSizeInMicrons, double baseDownsample, String matchingString) { + Path rootdir = Paths.get(folderPath) + List allFileRegionMaps = [] + + // Iterate over each subdirectory in the root directory + Files.newDirectoryStream(rootdir).each { path -> + if (Files.isDirectory(path) && path.fileName.toString().contains(matchingString)) { + // Process each subdirectory and collect file-region mappings + def fileRegionMaps = processSubDirectory(path, pixelSizeInMicrons, baseDownsample) + allFileRegionMaps += fileRegionMaps + } + } + + // Check if any valid file-region mappings were found + if (allFileRegionMaps.isEmpty()) { + Dialogs.showWarningNotification("Warning", "No valid tile configurations found in any subdirectory.") + return [] + } + + allFileRegionMaps + } + + /** + * Processes a single subdirectory to generate file-region mappings for stitching. + * This method reads the TileConfiguration.txt file in the directory (if present), + * collects all TIFF files, and creates a mapping of each file to its corresponding image region. + * + * @param dir The path to the subdirectory to be processed. + * @param pixelSizeInMicrons The pixel size in microns for calculating image regions. + * @param baseDownsample The base downsample value for the stitching process. + * @return A list of maps, each map containing file, region, and subdirName for stitching. + */ + private static List processSubDirectory(Path dir, double pixelSizeInMicrons, double baseDownsample) { + def logger = LoggerFactory.getLogger(QuPathGUI.class) + logger.info("Processing slide in folder $dir") + + // Check for the existence of TileConfiguration.txt in the directory + Path tileConfigPath = dir.resolve("TileConfiguration.txt") + if (!Files.exists(tileConfigPath)) { + logger.info("Skipping folder as TileConfiguration.txt is missing: $dir") + return [] + } + def tileConfig = parseTileConfiguration(tileConfigPath.toString()) + + logger.info('completed parseTileConfiguration') + + // Collect all TIFF files in the directory + List files = [] + Files.newDirectoryStream(dir, "*.tif*").each { path -> + files << path.toFile() + } + // Extract file names from tileConfig + Set tileConfigFileNames = tileConfig.collect { it.imageName } + + // Extract file names from the directory + Set directoryFileNames = files.collect { it.name } + + // Check if tileConfig file names match with the actual file names in the directory + if (!tileConfigFileNames.equals(directoryFileNames)) { + logger.warn("Mismatch between tile configuration file names and actual file names in directory: $dir") + + return [] // Optionally skip processing if there is a mismatch + } + // Create file-region mappings for each file + List fileRegionMaps = [] + files.each { File file -> + logger.info("parsing region from file $file") + ImageRegion region = parseRegionFromTileConfig(file as File, tileConfig as List) + if (region) { + logger.info("Processing file: ${file.path}") + fileRegionMaps << [file: file, region: region, subdirName: dir.getFileName().toString()] + } + } + + return fileRegionMaps + } + /** + * Parses the 'TileConfiguration.txt' file to extract image names and their coordinates. + * The function reads each line of the file, ignoring comments and blank lines. + * It extracts the image name and coordinates, then stores them in a list. + * + * @param filePath The path to the 'TileConfiguration.txt' file. + * @return A list of maps, each containing the image name and its coordinates (x, y). + */ + static def parseTileConfiguration(String filePath) { + def lines = Files.readAllLines(Paths.get(filePath)) + def imageCoordinates = [] + + lines.each { line -> + if (!line.startsWith("#") && !line.trim().isEmpty()) { + def parts = line.split(";") + if (parts.length >= 3) { + def imageName = parts[0].trim() + def coordinates = parts[2].trim().replaceAll("[()]", "").split(",") + imageCoordinates << [imageName: imageName, x: Double.parseDouble(coordinates[0]), y: Double.parseDouble(coordinates[1])] + } + } + } + + return imageCoordinates + } + /** + * Parse an ImageRegion from the TileConfiguration.txt data and TIFF file dimensions. + * @param imageName Name of the image file for which to get the region. + * @param tileConfig List of tile configurations parsed from TileConfiguration.txt. + * @param z index of z plane. + * @param t index of timepoint. + * @return An ImageRegion object representing the specified region of the image. + */ + static ImageRegion parseRegionFromTileConfig(File file, List tileConfig, int z = 0, int t = 0) { + String imageName = file.getName() + def config = tileConfig.find { it.imageName == imageName } + + if (config) { + int x = config.x as int + int y = config.y as int + def dimensions = UtilityFunctions.getTiffDimensions(file) + if (dimensions == null) { + logger.info("Could not retrieve dimensions for image $imageName") + return null + } + int width = dimensions.width + int height = dimensions.height + //logger.info( x+" "+y+" "+ width+ " " + height) + return ImageRegion.createInstance(x, y, width, height, z, t) + } else { + logger.info("No configuration found for image $imageName") + return null + } + } +} + + +/** + * Strategy for stitching images based on Vectra metadata. + * This class implements the {@link StitchingStrategy} interface, providing an algorithm + * for processing and stitching images based on metadata from Vectra imaging systems. + */ +class VectraMetadataStrategy implements StitchingStrategy { + + /** + * Prepares stitching by processing each subdirectory within the specified root directory. + * This method iterates over each subdirectory that matches the given criteria and + * aggregates file-region mapping information for stitching. + * + * @param folderPath The path to the root directory containing image files. + * @param pixelSizeInMicrons The pixel size in microns, used for calculating image regions. + * @param baseDownsample The base downsample value for the stitching process. + * @param matchingString A string to match for selecting relevant subdirectories. + * @return A list of maps, each map containing file, region, and subdirName information for stitching. + */ + @Override + List prepareStitching(String folderPath, double pixelSizeInMicrons, double baseDownsample, String matchingString) { + Path rootdir = Paths.get(folderPath) + List allFileRegionMaps = [] + + // Iterate over each subdirectory in the root directory + Files.newDirectoryStream(rootdir).each { path -> + if (Files.isDirectory(path) && path.fileName.toString().contains(matchingString)) { + // Process each subdirectory and collect file-region mappings + def fileRegionMaps = processSubDirectory(path, pixelSizeInMicrons, baseDownsample) + allFileRegionMaps += fileRegionMaps + } + } + + // Check if any valid file-region mappings were found + if (allFileRegionMaps.isEmpty()) { + Dialogs.showWarningNotification("Warning", "No valid tile configurations found in any subdirectory.") + return [] + } + + allFileRegionMaps + } + + /** + * Processes a single subdirectory to generate file-region mappings for stitching. + * This method collects all TIFF files in the directory and creates a mapping of each file + * to its corresponding image region based on Vectra metadata. + * + * @param dir The path to the subdirectory to be processed. + * @param pixelSizeInMicrons The pixel size in microns for calculating image regions. + * @param baseDownsample The base downsample value for the stitching process. + * @return A list of maps, each map containing file, region, and subdirName for stitching. + */ + private static List processSubDirectory(Path dir, double pixelSizeInMicrons, double baseDownsample) { + def logger = LoggerFactory.getLogger(QuPathGUI.class) + logger.info("Processing slide in folder $dir") + + // Collect all TIFF files in the directory + List files = [] + Files.newDirectoryStream(dir, "*.tif*").each { path -> + files << path.toFile() + } + logger.info('Parsing regions from ' + files.size() + ' files...') + + // Create file-region mappings for each file + List fileRegionMaps = [] + files.each { File file -> + ImageRegion region = parseRegion(file as File) + if (region) { + fileRegionMaps << [file: file, region: region, subdirName: dir.getFileName().toString()] + } + } + + return fileRegionMaps + } + + /** + * Parses the image region from a given file based on Vectra metadata. + * This method checks if the file is a TIFF and then parses the region from the TIFF file. + * + * @param file The image file for which to parse the region. + * @param z The z-plane index of the image (default is 0). + * @param t The timepoint index of the image (default is 0). + * @return An ImageRegion object representing the specified region of the image, or null if not found. + */ + static ImageRegion parseRegion(File file, int z = 0, int t = 0) { + if (checkTIFF(file)) { + try { + return parseRegionFromTIFF(file, z, t) + } catch (Exception e) { + print e.getLocalizedMessage() + } + } + } + + /** + * Checks if the provided file is a TIFF image by examining its 'magic number'. + * TIFF files typically start with a specific byte order indicator (0x4949 for little-endian + * or 0x4d4d for big-endian), followed by a fixed number (42 or 43). + * + * @param file The file to be checked. + * @return True if the file is a TIFF image, false otherwise. + */ + static boolean checkTIFF(File file) { + file.withInputStream { + def bytes = it.readNBytes(4) + short byteOrder = toShort(bytes[0], bytes[1]) + + // Interpret the next two bytes based on the byte order + int val + if (byteOrder == 0x4949) { // Little-endian + val = toShort(bytes[3], bytes[2]) + } else if (byteOrder == 0x4d4d) { // Big-endian + val = toShort(bytes[2], bytes[3]) + } else + return false + + return val == 42 || val == 43 // TIFF magic number + } + } + + /** + * Converts two bytes into a short, in the specified byte order. + * + * @param b1 The first byte. + * @param b2 The second byte. + * @return The combined short value. + */ + static short toShort(byte b1, byte b2) { + return (b1 << 8) + (b2 << 0) + } + + /** + * Parses the image region from a TIFF file using metadata information. + * Reads TIFF metadata to determine the image's physical position and dimensions, + * then calculates and returns the corresponding ImageRegion object. + * + * @param file The TIFF image file to parse. + * @param z The z-plane index of the image (default is 0). + * @param t The timepoint index of the image (default is 0). + * @return An ImageRegion object representing the specified region of the image. + */ + static ImageRegion parseRegionFromTIFF(File file, int z = 0, int t = 0) { + int x, y, width, height + file.withInputStream { + def reader = ImageIO.getImageReadersByFormatName("TIFF").next() + reader.setInput(ImageIO.createImageInputStream(it)) + def metadata = reader.getImageMetadata(0) + def tiffDir = TIFFDirectory.createFromMetadata(metadata) + + // Extract resolution and position values from the metadata + double xRes = getRational(tiffDir, BaselineTIFFTagSet.TAG_X_RESOLUTION) + double yRes = getRational(tiffDir, BaselineTIFFTagSet.TAG_Y_RESOLUTION) + double xPos = getRational(tiffDir, BaselineTIFFTagSet.TAG_X_POSITION) + double yPos = getRational(tiffDir, BaselineTIFFTagSet.TAG_Y_POSITION) + + // Extract image dimensions from the metadata + width = tiffDir.getTIFFField(BaselineTIFFTagSet.TAG_IMAGE_WIDTH).getAsLong(0) as int + height = tiffDir.getTIFFField(BaselineTIFFTagSet.TAG_IMAGE_LENGTH).getAsLong(0) as int + + // Calculate the x and y coordinates in the final stitched image + x = Math.round(xRes * xPos) as int + y = Math.round(yRes * yPos) as int + } + return ImageRegion.createInstance(x, y, width, height, z, t) + } + + /** + * Extracts a rational number from TIFF metadata based on the specified tag. + * The rational number is represented as a fraction (numerator/denominator). + * + * @param tiffDir The TIFFDirectory object containing the metadata. + * @param tag The metadata tag to extract the rational number from. + * @return The rational number as a double. + */ + static double getRational(TIFFDirectory tiffDir, int tag) { + long[] rational = tiffDir.getTIFFField(tag).getAsRational(0) + return rational[0] / (double) rational[1] + } +} + + +/** + * Class responsible for managing stitching strategies and executing the stitching process. + * This class sets the appropriate stitching strategy based on the given type and coordinates the stitching process. + */ +class StitchingImplementations { + private static StitchingStrategy strategy + + /** + * Sets the stitching strategy to be used. + * + * @param strategy The stitching strategy to be set. + */ + static void setStitchingStrategy(StitchingStrategy strategy) { + StitchingImplementations.strategy = strategy + } + + /** + * Core method to perform stitching based on the specified stitching type and other parameters. + * This method determines the stitching strategy, prepares stitching, and then performs the stitching process. + * + * @param stitchingType The type of stitching to be performed. + * @param folderPath The path to the folder containing images to be stitched. + * @param compressionType The type of compression to be used in the stitching output. + * @param pixelSizeInMicrons The size of a pixel in microns. + * @param baseDownsample The base downsample value for the stitching process. + * @param matchingString A string to match for selecting relevant subdirectories or files. + */ + static String stitchCore(String stitchingType, String folderPath, String outputPath, + String compressionType, double pixelSizeInMicrons, + double baseDownsample, String matchingString) { + def logger = LoggerFactory.getLogger(QuPathGUI.class) + // Determine the stitching strategy based on the provided type + logger.info("Stitching type is: $stitchingType") + switch (stitchingType) { + + case "Filename[x,y] with coordinates in microns": + setStitchingStrategy(new FileNameStitchingStrategy()) + break + case "Vectra tiles with metadata": + setStitchingStrategy(new VectraMetadataStrategy()) + break + case "Coordinates in TileConfiguration.txt file": + setStitchingStrategy(new TileConfigurationTxtStrategy()) + break + default: + Dialogs.showWarningNotification("Warning", "Error with choosing a stitching method, code here should not be reached in StitchingImplementations.groovy") + return // Safely exit the method if the stitching type is not recognized + } + + // Proceed with the stitching process if a valid strategy is set + if (strategy) { + // Prepare stitching by processing the folder with the selected strategy + def fileRegionPairs = strategy.prepareStitching(folderPath, pixelSizeInMicrons, baseDownsample, matchingString) + OMEPyramidWriter.CompressionType compression = UtilityFunctions.getCompressionType(compressionType) + def builder = new SparseImageServer.Builder() + + // Check if valid file-region pairs were obtained + if (fileRegionPairs == null || fileRegionPairs.isEmpty()) { + Dialogs.showWarningNotification("Warning", "No valid folders found matching the criteria.") + return // Exit the method if no valid file-region pairs are found + } + + def subdirName + // Process each file-region pair to build the image server for stitching + fileRegionPairs.each { pair -> + if (pair == null) { + logger.warn("Encountered a null pair in fileRegionPairs") + return // Skip this iteration if the pair is null + } + def file = pair['file'] as File + def region = pair['region'] as ImageRegion + subdirName = pair['subdirName'] as String // Extract subdirName from the pair + + if (file == null) { + logger.warn("File is null in pair: $pair") + return // Skip this iteration if the file is null + } + + if (region == null) { + logger.warn("Region is null in pair: $pair") + return // Skip this iteration if the region is null + } + + // Add the region to the image server builder + def serverBuilder = ImageServerProvider.getPreferredUriImageSupport(BufferedImage.class, file.toURI().toString()).getBuilders().get(0) + builder.jsonRegion(region, 1.0, serverBuilder) + } + + // Build and pyramidalize the server for the final stitched image + def server = builder.build() + server = ImageServers.pyramidalize(server) + + // Write the final stitched image + long startTime = System.currentTimeMillis() + def filename = subdirName ?: Paths.get(folderPath).getFileName().toString() + def outputFilePath = baseDownsample == 1 ? + Paths.get(outputPath).resolve(filename + '.ome.tif') : + Paths.get(outputPath).resolve(filename + '_' + (int) baseDownsample + 'x_downsample.ome.tif') + + //def fileOutput = outputFilePath.toFile() + //String pathOutput = fileOutput.getAbsolutePath() + String pathOutput = outputFilePath.toAbsolutePath().toString() + pathOutput = UtilityFunctions.getUniqueFilePath(pathOutput) + new OMEPyramidWriter.Builder(server) + .tileSize(512) + .channelsInterleaved() + .parallelize(true) + .compression(compression) + .scaledDownsampling(baseDownsample, 4) + .build() + .writePyramid(pathOutput) + + long endTime = System.currentTimeMillis() + logger.info("Image written to ${pathOutput} in ${GeneralTools.formatNumber((endTime - startTime) / 1000.0, 1)} s") + server.close() + return pathOutput + } else { + println("No valid stitching strategy set.") + } + } +} + diff --git a/src/main/groovy/qupath/ext/basicstitching/utilities/UtilityFunctions.groovy b/src/main/groovy/qupath/ext/basicstitching/utilities/UtilityFunctions.groovy new file mode 100644 index 0000000..c8b7551 --- /dev/null +++ b/src/main/groovy/qupath/ext/basicstitching/utilities/UtilityFunctions.groovy @@ -0,0 +1,131 @@ +package qupath.ext.basicstitching.utilities + +import org.slf4j.LoggerFactory +import qupath.lib.gui.QuPathGUI +import qupath.lib.images.writers.ome.OMEPyramidWriter + +import javax.imageio.ImageIO +import java.lang.reflect.Modifier +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Class containing utility functions used throughout the application. + */ +class UtilityFunctions { + static ArrayList getCompressionTypeList() { + def compressionTypeClass = OMEPyramidWriter.CompressionType + +// Retrieve all declared fields in the class + def fields = compressionTypeClass.declaredFields + +// Filter only public static final fields + def compressionTypes = fields.findAll { + Modifier.isPublic(it.modifiers) && + Modifier.isStatic(it.modifiers) && + Modifier.isFinal(it.modifiers) + } + +// Extract the names of the fields + def compressionTypeNames = compressionTypes*.name + return compressionTypeNames + } + /** + * Gets the compression type for OMEPyramidWriter based on the selected option. + * + * @param selectedOption The selected compression option as a string. + * @return The corresponding OMEPyramidWriter.CompressionType. + * @throws IllegalArgumentException if the selected option does not match any compression type. + */ + static OMEPyramidWriter.CompressionType getCompressionType(String selectedOption) throws IllegalArgumentException { + try { + // Convert the string to an enum constant + return OMEPyramidWriter.CompressionType.valueOf(selectedOption); + } catch (IllegalArgumentException e) { + // Throw an exception if no matching compression type is found + throw new IllegalArgumentException("Invalid compression type: " + selectedOption); + } + } +/** + * Generates a unique file path by appending a number to the file name if a file with the + * same name already exists. The first instance of the file will have no number appended, + * while the second will have _2, the third _3, and so on. + * + * @param originalPath The original file path. + * @return A unique file path. + */ + static String getUniqueFilePath(String originalPath) { + Path path = Paths.get(originalPath); + String baseName = path.getFileName().toString().replaceAll('\\.ome\\.tif$', ""); + Path parentDir = path.getParent(); + + // Start with the original base name + Path newPath = parentDir.resolve(baseName + ".ome.tif"); + int counter = 2; + + // If the file exists, start appending numbers + while (Files.exists(newPath)) { + newPath = parentDir.resolve(baseName + "_" + counter + ".ome.tif"); + counter++; + } + + return newPath.toString(); + } + + /** + * Retrieves the dimensions (width and height) of a TIFF image file. + * + * @param filePath The file path of the TIFF image. + * @return A map containing the 'width' and 'height' of the image, or null if an error occurs. + */ + static Map getTiffDimensions(File filePath) { + def logger = LoggerFactory.getLogger(QuPathGUI.class) + + // Check if the file exists + if (!filePath.exists()) { + logger.info("File not found: $filePath") + return null + } + + try { + // Read the image file + def image = ImageIO.read(filePath) + if (image == null) { + logger.info("ImageIO returned null for file: $filePath") + return null + } + + // Return the image dimensions as a map + return [width: image.getWidth(), height: image.getHeight()] + } catch (IOException e) { + // Log and handle the error + logger.info("Error reading the image file $filePath: ${e.message}") + return null + } + } +} + +//TODO Move this somewhere +// List prepareStitching(String folderPath, double pixelSizeInMicrons, double baseDownsample, String matchingString) { +// def logger = LoggerFactory.getLogger(QuPathGUI.class) +// Path rootdir = Paths.get(folderPath) +// List allFileRegionMaps = [] // This will store the file-region maps for all subdirectories +// def subdir = [] +// +// Files.newDirectoryStream(rootdir).each { path -> +// if (Files.isDirectory(path) && path.fileName.toString().contains(matchingString)) { +// logger.info("Processing: $path") +// def fileRegionMaps = processSubDirectory(path, pixelSizeInMicrons, baseDownsample) +// allFileRegionMaps += fileRegionMaps +// } +// } +// +// if (allFileRegionMaps.isEmpty()) { +// Dialogs.showWarningNotification("Warning", "No valid tile configurations found in any subdirectory.") +// return +// } +// +// allFileRegionMaps +// } +//} diff --git a/src/main/resources/META-INF/services/qupath.lib.gui.extensions.QuPathExtension b/src/main/resources/META-INF/services/qupath.lib.gui.extensions.QuPathExtension new file mode 100644 index 0000000..6338450 --- /dev/null +++ b/src/main/resources/META-INF/services/qupath.lib.gui.extensions.QuPathExtension @@ -0,0 +1 @@ +qupath.ext.basicstitching.BasicStitchingExtension