import os import sys import csv import json import datetime import traceback import imp import re from ij import IJ, WindowManager from ij.plugin.frame import RoiManager from java.lang import Runnable, System from javax.swing import (JDialog, JPanel, JLabel, JComboBox, JCheckBox, JButton, BorderFactory, JProgressBar, SwingWorker, SwingUtilities, JOptionPane) from javax.swing.border import EmptyBorder from java.awt import BorderLayout, FlowLayout, GridLayout, CardLayout def _discover_workflows(): """ Scan the workflows folder and import all BaseWorkflow subclasses. Returns a tuple of (dict of {display_name: workflow_instance}, list of error messages) """ workflows = {} errors = [] try: plugins_dir = IJ.getDirectory("plugins") toolkit_dir = os.path.join(plugins_dir, "Cell_Quantification_Toolkit") workflows_dir = os.path.join(toolkit_dir, "workflows") if not os.path.isdir(workflows_dir): IJ.log("Workflows directory not found: " + workflows_dir) return workflows, errors # Add workflows dir to path if not present if workflows_dir not in sys.path: sys.path.insert(0, workflows_dir) # Load BaseWorkflow class base_workflow_path = os.path.join(workflows_dir, 'base_workflow.py') if not os.path.exists(base_workflow_path): IJ.log("base_workflow.py not found") return workflows, errors base_namespace = {} execfile(base_workflow_path, base_namespace) BaseWorkflow = base_namespace['BaseWorkflow'] # Find workflow files workflow_files = [f for f in os.listdir(workflows_dir) if f.endswith('.py') and not f.startswith('_') and f != 'base_workflow.py'] # Load each workflow for filename in workflow_files: try: module_path = os.path.join(workflows_dir, filename) # Execute workflow file with BaseWorkflow in namespace namespace = {'BaseWorkflow': BaseWorkflow} with open(module_path, 'r') as f: source_code = f.read() compiled = compile(source_code, module_path, 'exec') exec(compiled, namespace) # Find and instantiate workflow classes for name, obj in namespace.items(): if isinstance(obj, type) and issubclass(obj, BaseWorkflow) and obj is not BaseWorkflow: instance = obj() workflows[instance.display_name] = instance except Exception as e: errors.append("{}: {}".format(filename, str(e))) IJ.log("Error loading workflow '{}': {}".format(filename, e)) IJ.log(traceback.format_exc()) except Exception as e: IJ.log("Error discovering workflows: " + str(e)) IJ.log(traceback.format_exc()) return workflows, errors def _sanitize_filename(name): """ Sanitize a string for use in filenames by replacing invalid characters. """ return re.sub(r'[^\w\-]', '_', name) class QuantificationDialog(JDialog): """ Modal dialog to configure settings for a batch quantification process. Dynamically loads workflows from the workflows folder. """ def __init__(self, parent_frame, selected_images): super(QuantificationDialog, self).__init__(parent_frame, "Quantification Settings", True) self.selected_images = selected_images self.settings = None self.models_dict = self._get_models() # Discover available workflows self.workflows_dict, self.workflow_errors = _discover_workflows() if self.workflow_errors: error_msg = "Some workflows failed to load:\n" + "\n".join(self.workflow_errors) JOptionPane.showMessageDialog(parent_frame, error_msg, "Workflow Loading Errors", JOptionPane.WARNING_MESSAGE) if not self.workflows_dict: IJ.log("Warning: No workflows found in workflows folder.") # Track current workflow's settings panel self.current_workflow_panel = None # Main panel main_panel = JPanel(BorderLayout(10, 10)) main_panel.setBorder(EmptyBorder(15, 15, 15, 15)) self.add(main_panel) # Info label info_text = "Ready to process {} selected images.".format(len(self.selected_images)) info_label = JLabel(info_text) main_panel.add(info_label, BorderLayout.NORTH) # Settings panel container settings_container = JPanel(BorderLayout(10, 10)) settings_container.setBorder(BorderFactory.createTitledBorder("Processing Options")) # Top: workflow selection + common options top_panel = JPanel(GridLayout(0, 2, 10, 10)) # Workflow selection workflow_names = list(self.workflows_dict.keys()) top_panel.add(JLabel("Choose Your Quantification Workflow:")) self.workflow_combo = JComboBox(workflow_names) self.workflow_combo.addActionListener(self._on_workflow_change) top_panel.add(self.workflow_combo) # Common display option top_panel.add(JLabel("Display Options:")) self.show_images_checkbox = JCheckBox("Show images during processing", False) top_panel.add(self.show_images_checkbox) settings_container.add(top_panel, BorderLayout.NORTH) # Workflow-specific settings panel (will be swapped dynamically) self.workflow_settings_container = JPanel(BorderLayout()) self.workflow_settings_container.setBorder(BorderFactory.createTitledBorder("Workflow Settings")) settings_container.add(self.workflow_settings_container, BorderLayout.CENTER) main_panel.add(settings_container, BorderLayout.CENTER) # Bottom button panel button_panel = JPanel(FlowLayout(FlowLayout.RIGHT)) run_button = JButton("Run", actionPerformed=self._run_action) cancel_button = JButton("Cancel", actionPerformed=self._cancel_action) button_panel.add(run_button) button_panel.add(cancel_button) main_panel.add(button_panel, BorderLayout.SOUTH) # Initialize with first workflow's settings panel self._on_workflow_change(None) self.pack() def _on_workflow_change(self, event): """Swap the settings panel based on selected workflow.""" selected_name = self.workflow_combo.getSelectedItem() if not selected_name: return workflow = self.workflows_dict.get(selected_name) if not workflow: return # Clear existing panel self.workflow_settings_container.removeAll() # Get workflow-specific panel self.current_workflow_panel = workflow.get_settings_panel(self.models_dict) if self.current_workflow_panel: self.workflow_settings_container.add(self.current_workflow_panel, BorderLayout.CENTER) else: # No custom settings for this workflow self.workflow_settings_container.add(JLabel("No additional settings for this workflow."), BorderLayout.CENTER) self.workflow_settings_container.revalidate() self.workflow_settings_container.repaint() self.pack() def _run_action(self, event): """Gathers settings into dictionary and closes dialog.""" selected_name = self.workflow_combo.getSelectedItem() workflow = self.workflows_dict.get(selected_name) if workflow: # Gather workflow-specific settings workflow_settings = workflow.gather_settings(self.current_workflow_panel) self.settings = { 'workflow': workflow, # Store workflow instance, not name 'workflow_name': selected_name, 'images': self.selected_images, 'show_images': self.show_images_checkbox.isSelected() } # Merge workflow-specific settings self.settings.update(workflow_settings) self.dispose() def _cancel_action(self,event): """ Leaves settings=None and closes dialog""" self.settings = None self.dispose() def show_dialog(self): """ Public method called by the GUI """ self.setLocationRelativeTo(self.getParent()) self.setVisible(True) return self.settings def _get_models(self): """ Finds models in the Cell_Quantification_Toolkit folder. Returns a dictionary of key:value pairs as display_name:full_path """ models = {} try: plugins_dir = IJ.getDirectory("plugins") plugin_folder_name = "Cell_Quantification_Toolkit" toolkit_dir = os.path.join(plugins_dir, plugin_folder_name) models_dir = os.path.join(toolkit_dir, "models") if os.path.isdir(models_dir): for f in os.listdir(models_dir): if f.lower().endswith('.ilp'): display_name = os.path.splitext(f)[0] full_path = os.path.join(models_dir, f) models[display_name] = full_path else: IJ.log("Model directory not found. Please create it at: " + models_dir) except Exception as e: IJ.log("Error discovering models: " + str(e)) IJ.log(traceback.format_exc()) return models class ProgressDialog(JDialog): """ A simple, modal dialog to display a progress bar. """ def __init__(self, parent_frame, title, max_value): super(ProgressDialog, self).__init__(parent_frame, title, True) self.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE) self.progress_bar = JProgressBar(0, max_value) self.progress_bar.setStringPainted(True) self.add(self.progress_bar) self.pack() self.setSize(400, 80) self.setLocationRelativeTo(parent_frame) class QuantificationWorker(SwingWorker): """ Processor Classs facilitating image quantification on a background thread given settings from the dialog """ def __init__(self, parent_gui, project, settings, progress_dialog): super(QuantificationWorker, self).__init__() self.parent_gui = parent_gui self.project = project self.settings = settings self.progress_dialog = progress_dialog self.all_results = [] def doInBackground(self): """ Processes each ROI individually after loading all ROIs from the zip file. Uses an index to create unique temporary filenames, preventing overwrites. """ # Generate unique run ID for this processing session (includes microseconds to prevent collisions) self.run_id = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f') # --- Helper class for updating the progress bar on the GUI thread --- class UpdateProgressBarTask(Runnable): def __init__(self, dialog, value): self.dialog = dialog self.value = value def run(self): self.dialog.progress_bar.setValue(self.value) images_to_process = self.settings['images'] # Set status to "Processing" at the beginning, storing previous status for rollback previous_statuses = {} for image_obj in images_to_process: previous_statuses[image_obj.filename] = image_obj.status image_obj.status = "Processing" # Immediately save and refresh the UI to show the "Processing" status self.project.sync_project_db() SwingUtilities.invokeLater(self.parent_gui.update_ui_for_project) # Calculate total ROIs from cached data (avoids reopening zip files) total_rois_to_process = sum(len(img.rois) for img in images_to_process if img.has_roi()) if total_rois_to_process == 0: return "No ROIs to process." roi_counter = 0 for image_obj in images_to_process: try: all_image_outlines = [] if self.isCancelled(): # Restore previous statuses on cancellation for img in images_to_process: if img.status == "Processing": img.status = previous_statuses.get(img.filename, "In Progress") break if not image_obj.has_roi(): continue imp_original = IJ.openImage(image_obj.full_path) if not imp_original: IJ.log("ERROR: Failed to open original image: " + image_obj.full_path) continue # 1. Load ALL ROIs from the .zip file ONCE per image. rm = RoiManager(True) rm.open(image_obj.roi_path) all_rois_for_image = rm.getRoisAsArray() rm.close() # 2. Loop through the loaded ROIs using enumerate to get a unique index 'i' for i, roi in enumerate(all_rois_for_image): if self.isCancelled(): # Restore previous statuses on cancellation for img in images_to_process: if img.status == "Processing": img.status = previous_statuses.get(img.filename, "In Progress") break temp_cropped_path = None try: # Read the bregma value directly from the ROI object's property bregma_val_str = roi.getProperty("comment") try: bregma_val = float(bregma_val_str) if bregma_val_str else 0.0 except (ValueError, TypeError): bregma_val = 0.0 # Get bounding box coordinates for offsetting results later roi_x = roi.getBounds().x roi_y = roi.getBounds().y # Create a duplicate for cropping to preserve the original image imp_cropped = imp_original.duplicate() imp_cropped.setRoi(roi) IJ.run(imp_cropped, "Crop", "") # 3. Add the unique index 'i' to the base_name to prevent file overwriting # Sanitize ROI name to remove characters invalid for filenames safe_roi_name = _sanitize_filename(roi.getName()) base_name = "{}_{}_{}".format(os.path.splitext(image_obj.filename)[0], safe_roi_name, i) temp_cropped_path = os.path.join(self.project.paths['temp'], base_name + "_cropped.tif") prob_map_path = os.path.join(self.project.paths['probabilities'], base_name) IJ.saveAs(imp_cropped, "Tiff", temp_cropped_path) imp_cropped.show() # Delegate to workflow plugin for processing workflow = self.settings.get('workflow') if workflow: # Run workflow-specific processing result_imp = workflow.process_roi(imp_cropped, temp_cropped_path, prob_map_path, self.settings) if not self.settings.get('show_images', False): if imp_cropped and imp_cropped.isVisible(): imp_cropped.close() # Analyze the results using workflow plugin analysis = workflow.analyze_results(result_imp, roi, roi_x, roi_y) if not self.settings.get('show_images', False): if result_imp: result_imp.changes = False result_imp.close() if analysis.get('outlines'): # Translate outlines from cropped to absolute coordinates for outline in analysis['outlines']: bounds = outline.getBounds() outline.setLocation(bounds.x + roi_x, bounds.y + roi_y) all_image_outlines.extend(analysis['outlines']) # Collect the base result for this single ROI piece single_roi_result = { 'filename': image_obj.filename, 'roi_name': roi.getName(), 'roi_area': roi.getStatistics().area, 'bregma_value': bregma_val, 'processing_run_id': self.run_id, } # Add workflow-specific columns for col in workflow.get_result_columns(): if col in analysis: single_roi_result[col] = analysis[col] self.all_results.append(single_roi_result) except Exception as e: IJ.log("ERROR processing ROI #{} ('{}') in '{}': {}".format(i, roi.getName(), image_obj.filename, e)) IJ.log(traceback.format_exc()) continue finally: # Clean up temporary cropped file if temp_cropped_path and os.path.exists(temp_cropped_path): try: os.remove(temp_cropped_path) except Exception as ex: IJ.log("Warning: Could not delete temporary file " + temp_cropped_path) if not self.settings.get('show_images', True): self._cleanup_stray_windows() # Update progress roi_counter += 1 progress = int(100.0 * roi_counter / total_rois_to_process) update_task = UpdateProgressBarTask(self.progress_dialog, progress) SwingUtilities.invokeLater(update_task) # After processing all ROIs for an image, save the collected cell outlines if all_image_outlines: outline_rm = RoiManager(True) for outline_roi in all_image_outlines: outline_rm.addRoi(outline_roi) outline_rm.runCommand("Save", image_obj.outline_path) outline_rm.close() # Close the original image window if it's not meant to be shown if not self.settings.get('show_images', True) and imp_original and imp_original.isVisible(): imp_original.close() image_obj.status = "Completed" # Mark for final update except Exception as e: IJ.log("ERROR processing '{}': {}".format(image_obj.filename, e)) image_obj.status = "Failed" # Mark as failed continue # Move to the next image finally: IJ.run("Collect Garbage", "") System.gc() self._cleanup_stray_windows() return "Quantification completed successfully for {} ROIs.".format(roi_counter) def _cleanup_stray_windows(self): """Aggressively find and close any stray temporary image windows.""" # Get a list of all currently open image windows image_ids = WindowManager.getIDList() if not image_ids: return # Keywords found in the titles of temporary windows temp_keywords = ["_cropped", "_probabilities", "_objects", "mask of"] # Iterate over a copy of the list, as closing images can modify it for img_id in list(image_ids): img = WindowManager.getImage(img_id) if not img: continue title = img.getTitle().lower() # If the window title contains any of our keywords, close it if any(keyword in title for keyword in temp_keywords): img.changes = False # Prevent "Save changes?" dialog img.close() def _build_metadata(self): """ Build a metadata dictionary with all JSON-serializable settings. Automatically captures all workflow settings without requiring workflow-specific implementation. """ # Filter settings to only JSON-serializable values serializable_settings = {} for key, value in self.settings.items(): if isinstance(value, (str, int, float, bool, type(None))): serializable_settings[key] = value elif isinstance(value, (list, dict)): # Attempt to serialize, skip if it fails try: json.dumps(value) serializable_settings[key] = value except (TypeError, ValueError): pass # Skip non-serializable values workflow = self.settings.get('workflow') return { 'processed_date': datetime.datetime.now().isoformat(), 'workflow_name': self.settings.get('workflow_name', 'Unknown'), 'workflow_settings': serializable_settings, 'workflow_metadata': workflow.get_log_metadata(self.settings) if workflow else {}, 'images_processed': [img.filename for img in self.settings.get('images', [])], 'total_results': len(self.all_results) } def _save_processing_metadata(self): """ Save processing metadata to an append-only JSON log file. Each run is keyed by its unique run_id for traceability. """ try: metadata_path = os.path.join( os.path.dirname(self.project.paths['results_db']), 'processing_log.json' ) # Load existing log or create new existing = {} if os.path.exists(metadata_path): try: with open(metadata_path, 'r') as f: existing = json.load(f) except (ValueError, IOError): IJ.log("Warning: Could not read existing processing_log.json, creating new.") # Add this run's metadata existing[self.run_id] = self._build_metadata() # Write back with open(metadata_path, 'w') as f: json.dump(existing, f, indent=2) except Exception as e: IJ.log("Warning: Could not save processing metadata: " + str(e)) def done(self): """ Runs on GUI thread after background work is finished. """ try: if self.all_results: # Get workflow to retrieve custom column names workflow = self.settings.get('workflow') custom_columns = workflow.get_result_columns() if workflow else [] aggregated_results = {} bregma_data = {} for result in self.all_results: key = (result['filename'], result['roi_name']) if key not in aggregated_results: aggregated_results[key] = result.copy() bregma_data[key] = {'sum': result['bregma_value'], 'count': 1} else: # Sum the base quantitative value aggregated_results[key]['roi_area'] += result['roi_area'] # Sum workflow-specific numeric columns for col in custom_columns: if col in result and col in aggregated_results[key]: try: aggregated_results[key][col] += result[col] except TypeError: pass # Non-numeric column, skip aggregation # Add to sum and increment count for averaging bregma bregma_data[key]['sum'] += result['bregma_value'] bregma_data[key]['count'] += 1 # Calculate the average Bregma for each group for key, data in aggregated_results.items(): bregma_sum = bregma_data[key]['sum'] bregma_count = bregma_data[key]['count'] average_bregma = (bregma_sum / bregma_count) if bregma_count > 0 else 0 aggregated_results[key]['bregma_value'] = "{:.3f}".format(average_bregma) final_results_list = list(aggregated_results.values()) # Build headers: base columns + workflow-specific columns results_db_path = self.project.paths['results_db'] base_headers = ['filename', 'roi_name', 'roi_area', 'bregma_value', 'processing_run_id'] headers = base_headers + custom_columns file_exists = os.path.isfile(results_db_path) with open(results_db_path, 'a') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=headers, extrasaction='ignore') if not file_exists or os.path.getsize(results_db_path) == 0: writer.writeheader() writer.writerows(final_results_list) # Save processing metadata to JSON log self._save_processing_metadata() # Show final status message final_message = self.get() JOptionPane.showMessageDialog(self.progress_dialog, final_message, "Status", JOptionPane.INFORMATION_MESSAGE) except Exception as e: # This will catch errors from the background thread IJ.log(traceback.format_exc()) JOptionPane.showMessageDialog(self.progress_dialog, "An error occurred during processing:\n" + str(e), "Error", JOptionPane.ERROR_MESSAGE) for image in self.settings['images']: if image.status == "Processing": image.status = "Failed" finally: self.progress_dialog.dispose() image_ids = WindowManager.getIDList() if image_ids: # Iterate over a copy of the list, as closing images modifies the original list. for img_id in list(image_ids): img = WindowManager.getImage(img_id) if img: img.changes = False img.close() # Save the final "Completed" or "Failed" statuses and refresh the UI self.project.sync_project_db() self.parent_gui.update_ui_for_project()