Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,9 @@ class LcDialog {
case "points":
box.children.addAll(boldLabel((r.name ?: "points") + " (points)"), new Label(placePoints(appName, r)), tableView(r.path as String))
break
case "shapes":
box.children.addAll(boldLabel((r.name ?: "shapes") + " (outlines)"), wrapped(placeShapes(appName, r)))
break
case ["image", "labels"]:
def open = new Button("Open in QuPath")
def path = r.path as String
Expand Down Expand Up @@ -1031,6 +1034,57 @@ class LcDialog {
return xs.size() + " point(s) added to the open image as the annotation '" + name + "'."
}

static Label wrapped(String text) {
def l = new Label(text)
l.wrapText = true
return l
}

static final int MAX_SHAPES = 50000 // outlines added to the image; the rest are counted in the message

/** Outlines (GeoJSON Polygon / MultiPolygon, [x, y] with pixel centres at integers) become annotations named "<app>:<output> <label>"
* on the image they were found in (when that was the image open in QuPath); holes and parts are kept; numeric properties become
* measurements. Replace() removes the previous annotations of this output. */
String placeShapes(String appName, Map r) {
def data = qupath?.imageData
def target = r.apply_to ?: currentTool?.inputs?.find { it.type in ["image", "labels"] }?.name
def chosen = target ? imageChoiceAtRun[target] : null
if (data == null || chosen == null || !chosen.startsWith("Current image"))
return "Not placed on an image (the outlines were not found in the image open in QuPath)."
def collection = new Gson().fromJson(new File(r.path as String).getText("UTF-8"), Map)
def factory = new org.locationtech.jts.geom.GeometryFactory()
def ring = { List points -> factory.createLinearRing(points.collect { new org.locationtech.jts.geom.Coordinate((it[0] as double) + 0.5d, (it[1] as double) + 0.5d) } as org.locationtech.jts.geom.Coordinate[]) } // pixel centres
def prefix = appName + ":" + r.name
def objects = [], holes = 0, total = collection.features.size()
for (feature in collection.features) {
if (objects.size() >= MAX_SHAPES) break
def geometry = feature.geometry
def parts = geometry.type == "Polygon" ? [geometry.coordinates] : geometry.coordinates
def polygons = parts.collect { part ->
if (part.size() > 1) holes++
factory.createPolygon(ring(part[0] as List), part.drop(1).collect { ring(it as List) } as org.locationtech.jts.geom.LinearRing[])
}
def shape = polygons.size() == 1 ? polygons[0] : factory.createMultiPolygon(polygons as org.locationtech.jts.geom.Polygon[])
def obj = PathObjects.createAnnotationObject(qupath.lib.roi.GeometryTools.geometryToROI(shape, ImagePlane.getDefaultPlane()))
def label = feature.get("properties")?.get("label") // .get(): on a map, Groovy's feature.properties and feature["properties"] give the bean properties, not the JSON key
if (label instanceof Number && (label as double) == Math.floor(label as double)) label = (label as double).longValue() // Gson reads every number as a double: 1.0 -> 1
obj.name = prefix + " " + (label != null ? label : objects.size() + 1)
feature.get("properties")?.each { k, v -> if (v instanceof Number) obj.measurementList.put(k.toString(), v as double) } // numeric properties become measurements
objects << obj
}
def hierarchy = data.hierarchy
def replacing = currentTool.outputs.any { it.name == r.name && it.replace }
if (replacing) {
def previous = hierarchy.annotationObjects.findAll { it.name?.startsWith(prefix + " ") }
if (previous) hierarchy.removeObjects(previous, true) // Replace()
}
hierarchy.addObjects(objects)
def text = objects.size() + " outline(s) added to the open image as annotations named '" + prefix + " <label>'."
if (total > MAX_SHAPES) text += " Showing the first " + MAX_SHAPES + " of " + total + "."
if (holes) text += " " + holes + " outline(s) have holes (kept)."
return text
}

/** A small preview of a result image (first plane, first channel, scaled to the window); labels get a colour per label. */
static Node previewNode(String path, boolean labels) {
def server = ImageServers.buildServer(path)
Expand Down
71 changes: 71 additions & 0 deletions tests/gui_test_shapes_body.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@

// ================================================================== GUI test body (appended to the tool script by tests/run_gui_test.sh)
import javafx.embed.swing.SwingFXUtils
import javax.imageio.ImageIO
import java.util.concurrent.CountDownLatch
import java.awt.image.BufferedImage
import javafx.stage.Window

def OUT = new File(System.getProperty("lc.test.out", "/tmp/qp_shots")); OUT.mkdirs()
def FX = "/home/tester/q/fx"
def results = [:]
def fx = { Closure c ->
if (Platform.isFxApplicationThread()) return c()
def latch = new CountDownLatch(1); def box = [null, null]
Platform.runLater { try { box[0] = c() } catch (Throwable t) { box[1] = t } finally { latch.countDown() } }
latch.await()
if (box[1] != null) throw box[1]
return box[0]
}
def expect = { String name, boolean ok, Object detail = "" ->
results[name] = ok
println((ok ? "PASS " : "FAIL ") + name + (ok ? "" : " -> " + detail))
}
def shot = { String name, Stage st ->
Thread.sleep(600)
fx {
def img = st.scene.snapshot(null)
ImageIO.write(SwingFXUtils.fromFXImage(img, null), "png", new File(OUT, name + ".png"))
}
println "SHOT " + name
}
def waitIdle = { dlg, int seconds = 300 ->
def end = System.currentTimeMillis() + seconds * 1000
while (System.currentTimeMillis() < end) { Thread.sleep(200); if (!dlg.running && fx { !dlg.runButton.disable }) return true }
return false
}
def status = { dlg -> fx { dlg.status.text } }
def pick = { dlg, app, tool -> fx { dlg.appBox.value = app }; fx { dlg.toolBox.value = tool } }
def resultStages = { Window.getWindows().findAll { it instanceof Stage && it.showing && it.title?.contains(": ") && it.title != "LabConstrictor: last run" } }


Thread.start("lc-gui-test") {
try {
fx {
def dir = new File("/tmp/qp_project_shapes"); dir.deleteDir()
qupath.lib.gui.prefs.PathPrefs.imageTypeSettingProperty().set(qupath.lib.gui.prefs.PathPrefs.ImageTypeSetting.AUTO_ESTIMATE)
def project = qupath.lib.projects.Projects.createProject(dir, BufferedImage)
def server = qupath.lib.images.servers.ImageServers.buildServer("/home/user/LabConstrictor-Fiji/tests/fixtures/blobs.tif")
def entry = project.addImage(server.getBuilder()); entry.setImageName("blobs.tif")
project.syncChanges(); qupath.setProject(project); qupath.openImageEntry(entry); entry
}
Thread.sleep(2500)
def dlg = fx { lcShow() }
Thread.sleep(800)
pick(dlg, "interactions", "Outline the blobs"); Thread.sleep(1500)
def annotations = { fx { qupath.imageData.hierarchy.annotationObjects.findAll { it.name?.startsWith("interactions:outlines ") } } }
fx { dlg.run() }; Thread.sleep(1500); waitIdle(dlg, 120)
println "STATUS " + status(dlg)
def found = annotations()
expect("one_annotation_per_blob", found.size() == 4, found*.name)
expect("names_carry_the_label", found.any { it.name == "interactions:outlines 1" }, found*.name)
expect("measurements_from_properties", found.every { it.getMeasurementList().getMeasurementNames().containsAll(["area", "label"]) }, found.collect { it.getMeasurementList().getMeasurementNames() })
def ring = found.find { it.getMeasurementList().get("area") > 300 }
expect("hole_is_kept", ring != null && ring.getROI().getArea() < 20 * 20 - 40, ring?.getROI()?.getArea())
expect("annotations_are_on_the_blob", found.every { it.ROI.boundsX >= 0 && it.ROI.boundsX < 80 && it.ROI.boundsY >= 0 && it.ROI.boundsY < 60 }, found.collect { [it.ROI.boundsX, it.ROI.boundsY] })
shot("qp_shapes_1_after", dlg.stage)
fx { dlg.run() }; Thread.sleep(1500); waitIdle(dlg, 120)
expect("replace_leaves_one_set", annotations().size() == 4, annotations()*.name)
} catch (Throwable t) { t.printStackTrace() }
finally { println "RESULTS " + results; Thread.sleep(500); System.exit(0) }
}