Last active
April 15, 2025 22:39
-
-
Save grenkoca/338651ed17dee19d1a8fe521c7faab91 to your computer and use it in GitHub Desktop.
QuPath Import and Update Cells
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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