Skip to content

Instantly share code, notes, and snippets.

@grenkoca
Last active April 15, 2025 22:39
Show Gist options
  • Select an option

  • Save grenkoca/338651ed17dee19d1a8fe521c7faab91 to your computer and use it in GitHub Desktop.

Select an option

Save grenkoca/338651ed17dee19d1a8fe521c7faab91 to your computer and use it in GitHub Desktop.
QuPath Import and Update Cells
/**
* QuPath Script: Import External Measurements
*
* This script imports data from CSV/TSV files back into QuPath,
* allowing for integration of external analysis with QuPath objects.
*
* @author : Caleb Grenko
*/
guiscript=true
import qupath.lib.gui.scripting.QPEx
import qupath.lib.objects.PathObject
import qupath.lib.measurements.MeasurementList
import java.nio.file.Files
import java.nio.file.Paths
import qupath.lib.common.ColorTools
import qupath.lib.objects.classes.PathClass
import java.awt.Color
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
// ======= CONFIGURATION =======
// Add columns you want to import here
def columnsToImport = ['cell_type_hierarchical', 'needs_review']
// The column to use for classification (must be in columnsToImport)
def classificationColumn = 'cell_type_hierarchical'
// Class name prefix (can be empty string if no prefix desired)
def classPrefix = "Cluster "
// Set to true to add measurements to cells, false to only set classes
def addMeasurements = true
// Set to null for file chooser dialog, or set a specific path
def filePath = null
// ===========================
// Show file chooser if path not specified
if (filePath == null) {
def fileChooser = new JFileChooser()
fileChooser.setDialogTitle("Select data file to import")
def filter = new FileNameExtensionFilter("Data files (CSV/TSV)", "csv", "tsv")
fileChooser.setFileFilter(filter)
int result = fileChooser.showOpenDialog(null)
if (result != JFileChooser.APPROVE_OPTION) {
print "No file selected, exiting."
return
}
filePath = fileChooser.getSelectedFile().getAbsolutePath()
}
// Determine delimiter based on file extension
def delimiter = filePath.toLowerCase().endsWith('.tsv') ? '\t' : ','
print "Using delimiter: ${delimiter == '\t' ? 'tab' : 'comma'}"
// Load file contents
def lines
try {
lines = Files.readAllLines(Paths.get(filePath))
if (lines.isEmpty()) {
print "The file is empty."
return
}
} catch (Exception e) {
print "Error loading file: ${e.getMessage()}"
return
}
// Parse header
def header = lines[0].split(delimiter) as List
print "Found columns: ${header}"
// Find required column indices
def objectIdIndex = header.indexOf('Object ID')
if (objectIdIndex == -1) {
// Try to find the last occurrence if there are duplicates
for (int i = header.size() - 1; i >= 0; i--) {
if (header[i] == 'Object ID') {
objectIdIndex = i
break
}
}
if (objectIdIndex == -1) {
print "ERROR: Required column 'Object ID' not found in header."
return
}
}
// Check for requested columns
def columnIndices = [:]
columnsToImport.each { colName ->
def idx = header.indexOf(colName)
if (idx == -1) {
print "WARNING: Requested column '${colName}' not found in header."
} else {
columnIndices[colName] = idx
}
}
if (!columnIndices.containsKey(classificationColumn)) {
print "ERROR: Classification column '${classificationColumn}' not found."
return
}
// Build maps from Object ID → column values
def valueMaps = [:]
columnIndices.each { colName, idx ->
valueMaps[colName] = [:]
}
for (line in lines[1..-1]) {
def parts = line.split(delimiter, -1) // -1 to keep empty values
if (parts.size() <= objectIdIndex) continue
def objectId = parts[objectIdIndex].trim()
if (objectId.isEmpty()) continue
columnIndices.each { colName, idx ->
if (parts.size() > idx) {
valueMaps[colName][objectId] = parts[idx].trim()
}
}
}
// Verify data was loaded
def classMap = valueMaps[classificationColumn]
print "Mapped ${classMap.size()} object IDs to ${classificationColumn} values."
// Extract unique class values and remove duplicates
def uniqueValues = new LinkedHashSet(classMap.values())
print "Found ${uniqueValues.size()} unique ${classificationColumn} values: ${uniqueValues}"
// Create a map to store PathClass objects
def classNamesMap = [:]
def classObjects = []
// Create Unclassified class
def unknownRGB = ColorTools.makeRGB(127, 127, 127)
def unclassifiedClass = PathClass.fromString("Unclassified", unknownRGB)
classObjects.add(unclassifiedClass)
classNamesMap["Unclassified"] = unclassifiedClass
// Generate colors for each unique value
def i = 0
def total = uniqueValues.size()
uniqueValues.each { value ->
if (value.isEmpty()) return // Skip empty values
// Generate a color based on position in list
float hue = (float)i / total
float saturation = 0.9f // Slightly less than 1.0 for better visibility
float brightness = 0.9f // Slightly less than 1.0 for better visibility
Color hsbColor = Color.getHSBColor(hue, saturation, brightness)
int rgb = ColorTools.makeRGB(hsbColor.getRed(), hsbColor.getGreen(), hsbColor.getBlue())
// Create class name with optional prefix
def className = classPrefix.isEmpty() ? value : classPrefix + value
// Use modern PathClass creation method instead of deprecated factory
def pathClass = PathClass.fromString(className, rgb)
classObjects.add(pathClass)
classNamesMap[className] = pathClass
i++
}
// Update available classes in project
def pathClasses = getQuPath().getAvailablePathClasses()
// Get all cells in the current image
def cells = getCellObjects()
if (cells.isEmpty()) {
print "No cells found in the current image."
return
}
print "Iterating through ${cells.size()} cells on slide"
// Counter for matched cells
def matchedCount = 0
def errorCount = 0
// Apply classes and measurements to cells
for (cell in cells) {
def cellID = cell.getID().toString()
// Skip cells not in our map
if (!classMap.containsKey(cellID)) continue
// Get the classification value
def value = classMap[cellID]
if (value.isEmpty()) continue
try {
// Set the class
def className = classPrefix.isEmpty() ? value : classPrefix + value
def pathClass = classNamesMap[className]
if (pathClass != null) {
cell.setPathClass(pathClass)
} else {
// Fall back to Unclassified if class not found
cell.setPathClass(PathClass.StandardPathClasses.UNCLASSIFIED)
print "WARNING: Class '${className}' not found, using Unclassified for cell ${cellID}"
}
// Add measurements if requested
if (addMeasurements) {
columnIndices.each { colName, idx ->
if (valueMaps[colName].containsKey(cellID)) {
def val = valueMaps[colName][cellID]
// Try to convert to number if possible
try {
double numVal = Double.parseDouble(val)
cell.measurements.put(colName, numVal)
} catch (Exception e) {
// If not a number, set the class
// TODO: figure out how to process if the user
// wants to set as class or append as metadata
cell.setPathClass(getPathClass(className))
}
}
}
}
matchedCount++
} catch (Exception e) {
errorCount++
if (errorCount <= 5) {
// Only log the first few errors to avoid flooding the console
print "ERROR processing cell ${cellID}: ${e.getMessage()}"
}
}
}
print "Updated ${matchedCount} cells out of ${cells.size()} total cells."
if (errorCount > 0) {
print "WARNING: Encountered ${errorCount} errors while updating cells."
}
print "Import complete!"
// Show measurement table with new data
if (addMeasurements && matchedCount > 0) {
try {
getQuPath().getViewer().getImageData().getHierarchy().fireObjectMeasurementsChangedEvent(
this, cells
)
} catch (Exception e) {
print "NOTE: Could not refresh measurement table: ${e.getMessage()}"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment