Importing Coordinates for Cellular Detections for Analysis

Hello!

For a project I’m working on, I am collecting point-click data in the form of labeled x, y coordinates from an external viewer to classify certain cell types (from JSON). Using this data, I am hoping to run QuPath’s cellular detection algorithm and then allow for the cells to inherit the class of the X,Y coordinate it contains, so that I can then train an object classifier.

I’ve been looking around the forum, and I’ve only seen how to create Eclipse or Rectangle ROI objects from these coordinates. However, when I create these annotations, I am unable to train an object classifier because I believe these annotations may be too small and aren’t getting correctly propagated into the hierarchy. I then get a “You need to annotate objects with atleast two classifications in order to train an object classifier”. I’ve tried increasing the eclipse size (resolves around when size ~15), and this resolves this error, but this results in the annotations becoming off of what was originally annotated via point clicks, which I’m trying to avoid.

Here is the code I’m currently running:

// read in geojson of labeled x,y coordinates into variable text
def map = new Gson().fromJson(text, Map)
def size = 1
annotations = []

for (feat in map['features']) {
    def class = feat['properties']['label_name'].toString()
    def points = feat['coordinates']
    for (point in points){
    
        x = point[0]
        y = point[1]


        double roi_size = 1
        int z = 0
        int t = 0
        def plane = ImagePlane.getPlane(z, t)
        def roi = new EllipseROI(x,y,roi_size,roi_size, plane)
        def pathAnnotation = new PathAnnotationObject(roi)
        
        pathAnnotation.setPathClass(getPathClass(name))
        annotations << pathAnnotation
    }
   
}

hierarchy.addPathObjects(annotations)

I was wondering if there is a way to add labeled Point Objects to the annotation Hierarchy? And if so, Is there a way I can assign labels to cellular objects based on the label of the Point that is contained inside them?

Thanks!

I have occasionally run into this error when I have not annotated enough objects for the classifier, even though two classifications have been created.

Thank you - that is great to know. I will annotate more clicks and try again.

Thanks for your help!

1 Like

One last thing it could be is that the centroid of the cell must be within the object you create (or you can create a Point object that must be inside the cell). The centroids are the weighted center, so depending on how big your ellipse is, the ellipse may not contain the XY center in some cases.

Or, you may want to try an roi_size of 2 or 3.

Good to know – will try this. Do you know how I would be able to create Point objects and assign them a class via groovy script? From what I’ve seen, they don’t seem to behave like normal pathAnnotationObjects/ ROI objects.

Thanks again for your help!

1 Like

This is a script I wrote recently to read GeoJSON point objects into QuPath:

/**
 * Import manual points for an image from within a project.
 *
 * The script is compatible with 'Run for project' for batch import.
 *
 * @author Pete Bankhead
 */

import qupath.lib.io.GsonTools
import qupath.lib.objects.hierarchy.DefaultTMAGrid

import static qupath.lib.gui.scripting.QPEx.*

// Name of the subdirectory containing the TMA grid
def subDirectory = "Manual points"

// If true, don't check for existing points
boolean ignoreExisting = false

// Check we have an image open from a project
def hierarchy = getCurrentHierarchy()
if (hierarchy == null) {
    println "No image is available!"
    return
}
def name = getProjectEntry()?.getImageName()
if (name == null) {
    println "No image name found! Be sure to run this script with a project and image open."
    return
}

// Resist adding (potentially duplicate) points unless the user explicitly requests this
def existingPoints = getAnnotationObjects().findAll {it.getROI()?.isPoint()}
if (!ignoreExisting && !existingPoints.isEmpty()) {
    println "Point annotations are already present! Please delete these first, or set ignoreExisting = true at the start of this script."
    return
}

// Get the file where the grid should be exported
def path = buildFilePath(PROJECT_BASE_DIR, subDirectory, "${name}.geojson")
def file = new File(path)
if (!file.exists()) {
    println "{$file} does not exist! Please ensure the points are available for import."
    return
}

// Write the file
def type = new com.google.gson.reflect.TypeToken<List<qupath.lib.objects.PathObject>>() {}.getType()
def points = GsonTools.getInstance().fromJson(file.text, type)
hierarchy.insertPathObjects(points)

It makes some assumptions about image names/paths but hopefully it’s useful to show the idea.

It also assumes the GeoJSON file contains a FeatureCollection; for single features the type should be PathObject.class instead (rather than the awkward TypeToken from a list).

1 Like

Yep. The object is the collection of points, so it is a little weird to work with at first, and caused me a few hours headache when I started trying to work with them :slight_smile: Hopefully @petebankhead’s script will make that much easier.

1 Like

Thanks for your help, Pete and RA! I’ve adapted the above code to read in my geoJSON, which does contain a featureCollection object.

At first, I hadn’t wrapped the GeoJSON file in […] and encountered Expected BEGIN_ARRAY but was BEGIN_OBJECT at line 1 column 2 path $. I resolved that by adding the brackets, but now I am getting a null pointer when reading in my GeoJSON:

ERROR: NullPointerException at line 48: null

ERROR: qupath.lib.io.PathObjectTypeAdapters$PathObjectTypeAdapter.read(PathObjectTypeAdapters.java:299)
    qupath.lib.io.PathObjectTypeAdapters$PathObjectTypeAdapter.read(PathObjectTypeAdapters.java:190)
    com.google.gson.internal.bind.TypeAdapterRuntimeTypeWrapper.read(TypeAdapterRuntimeTypeWrapper.java:41)
    com.google.gson.internal.bind.CollectionTypeAdapterFactory$Adapter.read(CollectionTypeAdapterFactory.java:82)
    com.google.gson.internal.bind.CollectionTypeAdapterFactory$Adapter.read(CollectionTypeAdapterFactory.java:61)
    com.google.gson.Gson.fromJson(Gson.java:932)
    com.google.gson.Gson.fromJson(Gson.java:897)
    com.google.gson.Gson.fromJson(Gson.java:846)
    com.google.gson.Gson$fromJson$0.call(Unknown Source)
    org.codehaus.groovy.runtime.callsite.CallSiteArray.defaultCall(CallSiteArray.java:47)
    org.codehaus.groovy.runtime.callsite.AbstractCallSite.call(AbstractCallSite.java:125)
    org.codehaus.groovy.runtime.callsite.AbstractCallSite.call(AbstractCallSite.java:148)
    Script109.run(Script109.groovy:22)
    org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:317)
    org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:155)
    qupath.lib.gui.scripting.DefaultScriptEditor.executeScript(DefaultScriptEditor.java:926)
    qupath.lib.gui.scripting.DefaultScriptEditor.executeScript(DefaultScriptEditor.java:859)
    qupath.lib.gui.scripting.DefaultScriptEditor.executeScript(DefaultScriptEditor.java:782)
    qupath.lib.gui.scripting.DefaultScriptEditor$2.run(DefaultScriptEditor.java:1271)
    java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
    java.base/java.util.concurrent.FutureTask.run(Unknown Source)
    java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
    java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
    java.base/java.lang.Thread.run(Unknown Source)

Here is my geoJSON and code snippet for reference if it helps!

// ..
// define path above and do additional checks first
// ...

def file = new File(path)
if (!file.exists()) {
    println "{$file} does not exist! Please ensure the points are available for import."
    return
}


def type = new com.google.gson.reflect.TypeToken<List<qupath.lib.objects.PathObject>>() {}.getType()
// null pointer in line below when getting Instance from JSON
def points = GsonTools.getInstance().fromJson(file.text, type)
hierarchy.insertPathObjects(points)
[{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [49589,2262]
      },
      "properties": {
        "label_num": 0,
        "label_name": "Class0"
      }
  },
  {
    "type": "Feature",
    "geometry":{
      "type": "Point",
      "coordinates": [49761,22304]
    },
    "properties": {
      "label_num": 1,
      "label_name": "Class1"
    }
   }]
}]

Thanks again for your time, y’all have been extremely helpful. :smiley:

1 Like

Ah @druvpatel, I think I’m going to need to look at the GeoJSON spec again, it has been a while. It seems this method only works reliably for GeoJSON that QuPath has written itself.

Basically, if is failing because QuPath really wants to see an “id” property, and ideally there would be no "Features" object – the list would be at the top level. For example, this works:

[    
  {
      "type": "Feature",
      "id": "PathAnnotationObject",
      "geometry": {
        "type": "Point",
        "coordinates": [49589,2262]
      },
      "properties": {
        "label_num": 0,
        "label_name": "Class0"
      }
  },
  {
    "type": "Feature",
    "id": "PathAnnotationObject",
    "geometry":{
      "type": "Point",
      "coordinates": [49761,22304]
    },
    "properties": {
      "label_num": 1,
      "label_name": "Class1"
    }
   }
]

To overcome the feature limitation, this script works too (but it still needs the id):

text = """
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": "PathAnnotationObject",
      "geometry": {
        "type": "Point",
        "coordinates": [49589,2262]
      },
      "properties": {
        "label_num": 0,
        "label_name": "Class0"
      }
  },
  {
    "type": "Feature",
    "id": "PathAnnotationObject",
    "geometry":{
      "type": "Point",
      "coordinates": [49761,22304]
    },
    "properties": {
      "label_num": 1,
      "label_name": "Class1"
    }
   }]
}
"""

class MyFeatureCollection {
    String type
    List<PathObject> features
}

def points = GsonTools.getInstance().fromJson(text, MyFeatureCollection.class).features
print points

Finally, here’s a variation that I think leaves your current JSON intact:

text = """
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [49589,2262]
      },
      "properties": {
        "label_num": 0,
        "label_name": "Class0"
      }
  },
  {
    "type": "Feature",
    "geometry":{
      "type": "Point",
      "coordinates": [49761,22304]
    },
    "properties": {
      "label_num": 1,
      "label_name": "Class1"
    }
   }]
}
"""

import com.google.gson.*

def gson = GsonTools.getInstance()
def features = gson.fromJson(text, JsonObject.class).get("features").getAsJsonArray()
def annotations = []
for (obj in features) {
    obj.addProperty("id", "PathAnnotationObject")
    annotations << gson.fromJson(obj, PathObject)
}
addObjects(annotations)
2 Likes

This did the trick! Thank you @petebankhead and @Research_Associate :slight_smile:

I am able to change how we build the GeoJSONS from our external viewer, so I modified it to adapt to this (and add the classification label under properties as well).

Thank you guys so much for your quick and helpful responses!

Druv

1 Like