diff --git a/active_plugins/exporttoomerotable.py b/active_plugins/exporttoomerotable.py new file mode 100644 index 0000000..e69f290 --- /dev/null +++ b/active_plugins/exporttoomerotable.py @@ -0,0 +1,896 @@ +""" +ExportToOMEROTable +================== + +**ExportToOMEROTable** exports measurements directly into an +OMERO.table stored on an OMERO server. + +An uploaded table is viewable in OMERO.web, it will be uploaded as an attachment to +an existing OMERO object. + +# Installation - +Easy mode - clone the plugins repository and point your CellProfiler plugins folder to this folder. +Navigate to /active_plugins/ and run `pip install -e .[omero]` to install dependencies. + +## Manual Installation + +Add this file plus the `omero_helper` directory into your CellProfiler plugins folder. Install dependencies into +your CellProfiler Python environment. + +## Installing dependencies - +This depends on platform. At the most basic level you'll need the `omero-py` package and the `omero_user_token` package. + +Both should be possible to pip install on Windows. On MacOS, you'll probably have trouble with the zeroc-ice dependency. +omero-py uses an older version and so needs specific wheels. Fortunately we've built some for you. +Macos - https://github.com/glencoesoftware/zeroc-ice-py-macos-x86_64/releases/latest +Linux (Generic) - https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/latest +Ubuntu 22.04 - https://github.com/glencoesoftware/zeroc-ice-py-ubuntu2204-x86_64/releases/latest + +Download the .whl file from whichever is most appropriate and run `pip install `. + +From there pip install omero-py should do the rest. + +You'll also want the `omero_user_token` package to help manage logins (`pip install omero_user_token`). +This allows you to set reusable login tokens for quick reconnection to a server. These tokens are required for using +headless mode/analysis mode. + +# Limitations + +- OMERO tables cannot have their columns changed after being initialised. For now, +this means that measurements cannot be added after the pipeline finishes (e.g. per-well averages). +For most use cases you can export all measurements produced by the pipeline, it'll be results from +complex modules like the LAP tracker in TrackObjects which cannot be fully exported. + +- Groupings from the Groups module are currently not implemented. Everything goes into a single table. +This may be added in a future version, but for now a single table per image/object type is created +without support for splitting (much like ExportToDatabase). + +- There is a limit to how much data can be transmitted to OMERO in a single operation. This +causes issues when creating very large tables. In practice you may encounter issues when trying to +export a table with more than ~600 columns, depending on the column name lengths. + +| + +============ ============ =============== +Supports 2D? Supports 3D? Respects masks? +============ ============ =============== +YES YES YES +============ ============ =============== + + +""" + +import functools +import logging +import math +import os +import re +from collections.abc import Iterable + +import cellprofiler_core.pipeline +import cellprofiler_core.utilities.legacy +from cellprofiler_core.constants.measurement import AGG_MEAN +from cellprofiler_core.constants.measurement import AGG_MEDIAN +from cellprofiler_core.constants.measurement import AGG_STD_DEV +from cellprofiler_core.constants.measurement import EXPERIMENT +from cellprofiler_core.constants.measurement import M_NUMBER_OBJECT_NUMBER +from cellprofiler_core.constants.measurement import NEIGHBORS +from cellprofiler_core.constants.measurement import OBJECT +from cellprofiler_core.module import Module +from cellprofiler_core.preferences import get_headless +from cellprofiler_core.setting import Binary +from cellprofiler_core.setting import ValidationError +from cellprofiler_core.setting.choice import Choice +from cellprofiler_core.setting.do_something import DoSomething +from cellprofiler_core.setting.subscriber import LabelListSubscriber +from cellprofiler_core.setting.text import Integer, Text +from cellprofiler_core.utilities.measurement import agg_ignore_feature +from cellprofiler_core.constants.measurement import COLTYPE_INTEGER +from cellprofiler_core.constants.measurement import COLTYPE_FLOAT +from cellprofiler_core.constants.measurement import COLTYPE_VARCHAR +from cellprofiler_core.constants.measurement import COLTYPE_BLOB +from cellprofiler_core.constants.measurement import COLTYPE_MEDIUMBLOB +from cellprofiler_core.constants.measurement import COLTYPE_LONGBLOB + +import omero +import omero.grid + +from omero_helper.connect import CREDENTIALS, login + +LOGGER = logging.getLogger(__name__) + +############################################## +# +# Keyword for the cached data +# +############################################## +# Measurement column info +D_MEASUREMENT_COLUMNS = "MeasurementColumns" +# OMERO table locations/metadata. +OMERO_TABLE_KEY = "OMERO_tables" + +"""The column name for the image number column""" +C_IMAGE_NUMBER = "ImageNumber" + +"""The column name for the object number column""" +C_OBJECT_NUMBER = "ObjectNumber" + + +############################################## +# +# Choices for which objects to include +# +############################################## + +"""Put all objects in the database""" +O_ALL = "All" +"""Don't put any objects in the database""" +O_NONE = "None" +"""Select the objects you want from a list""" +O_SELECT = "Select..." + + +############################################## +# +# Constants for interacting with the OMERO tables API +# +############################################## +# Map from CellProfiler - OMERO column type +COLUMN_TYPES = { + COLTYPE_INTEGER: omero.grid.LongColumn, + COLTYPE_FLOAT: omero.grid.DoubleColumn, + COLTYPE_VARCHAR: omero.grid.StringColumn, + COLTYPE_BLOB: omero.grid.StringColumn, + COLTYPE_MEDIUMBLOB: omero.grid.StringColumn, + COLTYPE_LONGBLOB: omero.grid.StringColumn, +} + +# OMERO columns with special meaning +SPECIAL_NAMES = { + 'roi': omero.grid.RoiColumn, + 'image': omero.grid.ImageColumn, + 'dataset': omero.grid.DatasetColumn, + 'well': omero.grid.WellColumn, + 'field': omero.grid.ImageColumn, + 'wellsample': omero.grid.ImageColumn, + 'plate': omero.grid.PlateColumn, +} + +# Link annotations needed for each parent type +LINK_TYPES = { + "Image": omero.model.ImageAnnotationLinkI, + "Dataset": omero.model.DatasetAnnotationLinkI, + "Screen": omero.model.ScreenAnnotationLinkI, + "Plate": omero.model.PlateAnnotationLinkI, + "Well": omero.model.WellAnnotationLinkI, +} + +# OMERO types for each parent type +OBJECT_TYPES = { + "Image": omero.model.ImageI, + "Dataset": omero.model.DatasetI, + "Screen": omero.model.ScreenI, + "Plate": omero.model.PlateI, + "Well": omero.model.WellI, +} + + +class ExportToOMEROTable(Module): + module_name = "ExportToOMEROTable" + variable_revision_number = 1 + category = ["File Processing", "Data Tools"] + + def create_settings(self): + self.target_object_type = Choice( + "OMERO parent object type", + ["Image", "Dataset", "Project", "Screen", "Plate"], + doc="""\ + The created OMERO.table must be associated with an existing object + in OMERO. Select the type of object you'd like to attach the table + to.""" + ) + + self.target_object_id = Integer( + text="OMERO ID of the parent object", + minval=1, + doc="""\ + The created OMERO.table must be associated with an existing object + in OMERO. Enter the OMERO ID of the object you'd like to associate + the table(s) with. This ID can be found by locating the target object + in OMERO.web (ID and type is displayed in the right panel).""", + ) + + + self.test_connection_button = DoSomething( + "Test the OMERO connection", + "Test connection", + self.test_connection, + doc="""\ +This button test the connection to the OMERO server specified using +the settings entered by the user.""", + ) + + self.want_table_prefix = Binary( + "Add a prefix to table names?", + True, + doc="""\ +Select whether you want to add a prefix to your table names. The default +table names are *Per\_Image* for the per-image table and *Per\_Object* +for the per-object table. Adding a prefix can be useful for bookkeeping +purposes. + +- Select "*{YES}*" to add a user-specified prefix to the default table + names. If you want to distinguish multiple sets of data written to + the same database, you probably want to use a prefix. +- Select "*{NO}*" to use the default table names. For a one-time export + of data, this option is fine. + +Whether you chose to use a prefix or not, CellProfiler will warn you if +your choice entails overwriting an existing table. +""".format( + **{"YES": "Yes", "NO": "No"} + ), + ) + + self.table_prefix = Text( + "Table prefix", + "MyExpt_", + doc="""\ +*(Used if "Add a prefix to table names?" is selected)* + +Enter the table prefix you want to use. +""", + ) + + + self.wants_agg_mean = Binary( + "Calculate the per-image mean values of object measurements?", + True, + doc="""\ +Select "*Yes*" for **ExportToOMEROTable** to calculate population +statistics over all the objects in each image and store the results in +the database. For instance, if you are measuring the area of the Nuclei +objects and you check the box for this option, **ExportToOMEROTable** will +create a column in the Per\_Image table called +“Mean\_Nuclei\_AreaShape\_Area”. + +You may not want to use **ExportToOMEROTable** to calculate these +population statistics if your pipeline generates a large number of +per-object measurements; doing so might exceed table column limits. +""", + ) + + self.wants_agg_median = Binary( + "Calculate the per-image median values of object measurements?", + False, + doc="""\ +Select "*Yes*" for **ExportToOMEROTable** to calculate population +statistics over all the objects in each image and store the results in +the database. For instance, if you are measuring the area of the Nuclei +objects and you check the box for this option, **ExportToOMEROTable** will +create a column in the Per\_Image table called +“Median\_Nuclei\_AreaShape\_Area”. + +You may not want to use **ExportToOMEROTable** to calculate these +population statistics if your pipeline generates a large number of +per-object measurements; doing so might exceed table column limits. +""", + ) + + self.wants_agg_std_dev = Binary( + "Calculate the per-image standard deviation values of object measurements?", + False, + doc="""\ +Select "*Yes*" for **ExportToOMEROTable** to calculate population +statistics over all the objects in each image and store the results in +the database. For instance, if you are measuring the area of the Nuclei +objects and you check the box for this option, **ExportToOMEROTable** will +create a column in the Per\_Image table called +“StDev\_Nuclei\_AreaShape\_Area”. + +You may not want to use **ExportToOMEROTable** to calculate these +population statistics if your pipeline generates a large number of +per-object measurements; doing so might exceed database column limits. +""", + ) + + self.objects_choice = Choice( + "Export measurements for all objects to OMERO?", + [O_ALL, O_NONE, O_SELECT], + doc="""\ +This option lets you choose the objects whose measurements will be saved +in the Per\_Object and Per\_Well(s) OMERO tables. + +- *{O_ALL}:* Export measurements from all objects. +- *{O_NONE}:* Do not export data to a Per\_Object table. Save only + Per\_Image measurements (which nonetheless include + population statistics from objects). +- *{O_SELECT}:* Select the objects you want to export from a list. +""".format( + **{"O_ALL": O_ALL, "O_NONE": O_NONE, "O_SELECT": O_SELECT} + ), + ) + + self.objects_list = LabelListSubscriber( + "Select object tables to export", + [], + doc="""\ + *(Used only when "Within objects" or "Both" are selected)* + + Select the objects to be measured.""", + ) + + def visible_settings(self): + result = [self.target_object_type, self.target_object_id, + self.test_connection_button, self.want_table_prefix] + if self.want_table_prefix.value: + result += [self.table_prefix] + # Aggregations + result += [self.wants_agg_mean, self.wants_agg_median, self.wants_agg_std_dev] + # Table choices (1 / separate object tables, etc) + result += [self.objects_choice] + if self.objects_choice == O_SELECT: + result += [self.objects_list] + return result + + def settings(self): + result = [ + self.target_object_type, + self.target_object_id, + self.want_table_prefix, + self.table_prefix, + self.wants_agg_mean, + self.wants_agg_median, + self.wants_agg_std_dev, + self.objects_choice, + self.objects_list, + ] + return result + + def help_settings(self): + return [ + self.target_object_type, + self.target_object_id, + self.want_table_prefix, + self.table_prefix, + self.wants_agg_mean, + self.wants_agg_median, + self.wants_agg_std_dev, + self.objects_choice, + self.objects_list, + ] + + def validate_module(self, pipeline): + if self.want_table_prefix.value: + if not re.match("^[A-Za-z][A-Za-z0-9_]+$", self.table_prefix.value): + raise ValidationError("Invalid table prefix", self.table_prefix) + + if self.objects_choice == O_SELECT: + if len(self.objects_list.value) == 0: + raise ValidationError( + "Please choose at least one object", self.objects_choice + ) + + def validate_module_warnings(self, pipeline): + """Warn user re: Test mode """ + if pipeline.test_mode: + raise ValidationError( + "ExportToOMEROTable does not produce output in Test Mode", self.target_object_id + ) + + def test_connection(self): + """Check to make sure the OMERO server is remotely accessible""" + # CREDENTIALS is a singleton so we can safely grab it here. + if CREDENTIALS.client is None: + login() + if CREDENTIALS.client is None: + msg = "OMERO connection failed" + else: + msg = f"Connected to {CREDENTIALS.server}" + else: + msg = f"Already connected to {CREDENTIALS.server}" + if CREDENTIALS.client is not None: + try: + self.get_omero_parent() + msg += f"\n\nFound parent object {self.target_object_id}" + except ValueError as ve: + msg += f"\n\n{ve}" + + import wx + wx.MessageBox(msg) + + def make_full_filename(self, file_name, workspace=None, image_set_index=None): + """Convert a file name into an absolute path + + We do a few things here: + * apply metadata from an image set to the file name if an + image set is specified + * change the relative path into an absolute one using the "." and "&" + convention + * Create any directories along the path + """ + if image_set_index is not None and workspace is not None: + file_name = workspace.measurements.apply_metadata( + file_name, image_set_index + ) + measurements = None if workspace is None else workspace.measurements + path_name = self.directory.get_absolute_path(measurements, image_set_index) + file_name = os.path.join(path_name, file_name) + path, file = os.path.split(file_name) + if not os.path.isdir(path): + os.makedirs(path) + return os.path.join(path, file) + + @staticmethod + def connect_to_omero(): + if CREDENTIALS.client is None: + if get_headless(): + connected = login() + if not connected: + raise ValueError("No OMERO connection established") + else: + login() + if CREDENTIALS.client is None: + raise ValueError("OMERO connection failed") + + def prepare_run(self, workspace): + """Prepare to run the pipeline. + Establish a connection to OMERO and create the necessary tables.""" + # Reset shared state + self.get_dictionary().clear() + + pipeline = workspace.pipeline + if pipeline.test_mode: + # Don't generate in test mode + return + + if pipeline.in_batch_mode(): + return True + + # Verify that we're able to connect to a server + self.connect_to_omero() + + shared_state = self.get_dictionary() + + # Add a list of measurement columns into the module state, and fix their order. + if D_MEASUREMENT_COLUMNS not in shared_state: + shared_state[D_MEASUREMENT_COLUMNS] = pipeline.get_measurement_columns() + shared_state[D_MEASUREMENT_COLUMNS] = self.filter_measurement_columns( + shared_state[D_MEASUREMENT_COLUMNS] + ) + + # Build a list of tables to create + column_defs = shared_state[D_MEASUREMENT_COLUMNS] + desired_tables = ["Image", "Relationships"] + if self.objects_choice == O_SELECT: + desired_tables += self.objects_list.value + elif self.objects_choice == O_ALL: + desired_tables += self.get_object_names(pipeline) + + # Construct a list of tables in the format (CP name, OMERO name, OMERO ID, CP columns) + omero_table_list = [] + parent = self.get_omero_parent() + + workspace.display_data.header = ["Output", "Table Name", "OMERO ID", "Server Location"] + workspace.display_data.columns = [] + + for table_name in desired_tables: + true_name = self.get_table_name(table_name) + table_cols = [("", "ImageNumber", COLTYPE_INTEGER)] + if table_name != "Image": + table_cols.append(("", "ObjectNumber", COLTYPE_INTEGER)) + if table_name == OBJECT: + target_names = set(self.get_object_names(pipeline)) + else: + target_names = {table_name} + table_cols.extend([col for col in column_defs if col[0] in target_names]) + if table_name == "Image": + # Add any aggregate measurements + table_cols.extend(self.get_aggregate_columns(workspace.pipeline)) + elif table_name == "Relationships": + # Hacky, but relationships are totally different from standard measurements + relationships = pipeline.get_object_relationships() + if not relationships: + # No need for table + continue + table_cols = [ + ("", "Module", COLTYPE_VARCHAR), + ("", "Module Number", COLTYPE_INTEGER), + ("", "Relationship", COLTYPE_VARCHAR), + ("", "First Object Name", COLTYPE_VARCHAR), + ("", "First Image Number", COLTYPE_INTEGER), + ("", "First Object Number", COLTYPE_INTEGER), + ("", "Second Object Name", COLTYPE_VARCHAR), + ("", "Second Image Number", COLTYPE_INTEGER), + ("", "Second Object Number", COLTYPE_INTEGER), + ] + omero_id = self.create_omero_table(parent, true_name, table_cols) + omero_table_list.append((table_name, true_name, omero_id, table_cols)) + table_path = f"https://{CREDENTIALS.server}/webclient/omero_table/{omero_id}" + LOGGER.info(f"Created table at {table_path}") + workspace.display_data.columns.append((table_name, true_name, omero_id, table_path)) + + shared_state[OMERO_TABLE_KEY] = omero_table_list + LOGGER.debug("Stored OMERO table info into shared state") + return True + + def get_omero_conn(self): + self.connect_to_omero() + return CREDENTIALS.get_gateway() + + def get_omero_parent(self): + conn = self.get_omero_conn() + parent_id = self.target_object_id.value + parent_type = self.target_object_type.value + old_group = conn.SERVICE_OPTS.getOmeroGroup() + # Search across groups + conn.SERVICE_OPTS.setOmeroGroup(-1) + parent_ob = conn.getObject(parent_type, parent_id) + if parent_ob is None: + raise ValueError(f"{parent_type} ID {parent_id} not found on server") + conn.SERVICE_OPTS.setOmeroGroup(old_group) + return parent_ob + + def create_omero_table(self, parent, table_name, column_defs): + """Creates a new OMERO table""" + conn = self.get_omero_conn() + parent_type = self.target_object_type.value + parent_id = self.target_object_id.value + parent_group = parent.details.group.id.val + + columns = self.generate_omero_columns(column_defs) + if len(columns) > 500: + LOGGER.warning(f"Large number of columns in table ({len(columns)})." + f"Plugin may encounter issues sending data to OMERO.") + resources = conn.c.sf.sharedResources(_ctx={ + "omero.group": str(parent_group)}) + repository_id = resources.repositories().descriptions[0].getId().getValue() + + table = None + try: + table = resources.newTable(repository_id, table_name, _ctx={ + "omero.group": str(parent_group)}) + table.initialize(columns) + LOGGER.info("Table creation complete, linking to image") + orig_file = table.getOriginalFile() + + # create file link + link_obj = LINK_TYPES[parent_type]() + target_obj = OBJECT_TYPES[parent_type](parent_id, False) + # create annotation + annotation = omero.model.FileAnnotationI() + # link table to annotation object + annotation.file = orig_file + + link_obj.link(target_obj, annotation) + conn.getUpdateService().saveObject(link_obj, _ctx={ + "omero.group": str(parent_group)}) + LOGGER.debug("Saved annotation link") + + LOGGER.info(f"Created table {table_name} under " + f"{parent_type} {parent_id}") + return orig_file.id.val + except Exception: + raise + finally: + if table is not None: + table.close() + + def get_omero_table(self, table_id): + conn = self.get_omero_conn() + old_group = conn.SERVICE_OPTS.getOmeroGroup() + # Search across groups + conn.SERVICE_OPTS.setOmeroGroup(-1) + table_file = conn.getObject("OriginalFile", table_id) + if table_file is None: + raise ValueError(f"OriginalFile ID {table_id} not found on server") + resources = conn.c.sf.sharedResources() + table = resources.openTable(table_file._obj) + conn.SERVICE_OPTS.setOmeroGroup(old_group) + return table + + def generate_omero_columns(self, column_defs): + omero_columns = [] + for object_name, measurement, column_type in column_defs: + if object_name: + column_name = f"{object_name}_{measurement}" + else: + column_name = measurement + cleaned_name = column_name.replace('/', '\\') + split_type = column_type.split('(', 1) + cleaned_type = split_type[0] + if column_name in SPECIAL_NAMES and column_type.kind == 'i': + col_class = SPECIAL_NAMES[column_name] + elif cleaned_type in COLUMN_TYPES: + col_class = COLUMN_TYPES[cleaned_type] + else: + raise NotImplementedError(f"Column type " + f"{cleaned_type} not supported") + if col_class == omero.grid.StringColumn: + if len(split_type) == 1: + max_len = 128 + else: + max_len = int(split_type[1][:-1]) + col = col_class(cleaned_name, "", max_len, []) + else: + col = col_class(cleaned_name, "", []) + omero_columns.append(col) + return omero_columns + + def run(self, workspace): + if workspace.pipeline.test_mode: + return + shared_state = self.get_dictionary() + omero_map = shared_state[OMERO_TABLE_KEY] + # Re-establish server connection + self.connect_to_omero() + + for table_type, table_name, table_file_id, table_columns in omero_map: + table = None + try: + table = self.get_omero_table(table_file_id) + self.write_data_to_omero(workspace, table_type, table, table_columns) + except: + LOGGER.error(f"Unable to write to table {table_name}", exc_info=True) + raise + finally: + if table is not None: + table.close() + + def write_data_to_omero(self, workspace, table_type, omero_table, column_list): + measurements = workspace.measurements + table_columns = omero_table.getHeaders() + # Collect any extra aggregate columns we might need. + extra_data = {C_IMAGE_NUMBER: measurements.image_set_number} + if table_type == "Image": + extra_data.update(measurements.compute_aggregate_measurements( + measurements.image_set_number, self.agg_names + )) + elif table_type == "Relationships": + # We build the Relationships table in the extra data buffer + modules = workspace.pipeline.modules() + # Initialise table variables as empty + extra_data["First Image Number"] = [] + extra_data["Second Image Number"] = [] + extra_data["First Object Number"] = [] + extra_data["Second Object Number"] = [] + extra_data["Module"] = [] + extra_data["Module Number"] = [] + extra_data["Relationship"] = [] + extra_data["First Object Name"] = [] + extra_data["Second Object Name"] = [] + for key in measurements.get_relationship_groups(): + # Add records for each relationship + records = measurements.get_relationships( + key.module_number, + key.relationship, + key.object_name1, + key.object_name2, + ) + module_name = modules[key.module_number].module_name + extra_data["First Image Number"] += list(records["ImageNumber_First"]) + extra_data["Second Image Number"] += list(records["ImageNumber_Second"]) + extra_data["First Object Number"] += list(records["ObjectNumber_First"]) + extra_data["Second Object Number"] += list(records["ObjectNumber_Second"]) + num_records = len(records["ImageNumber_First"]) + extra_data["Module"] += [module_name] * num_records + extra_data["Module Number"] += [key.module_number] * num_records + extra_data["Relationship"] += [key.relationship] * num_records + extra_data["First Object Name"] += [key.object_name1] * num_records + extra_data["Second Object Name"] += [key.object_name2] * num_records + else: + extra_data[C_OBJECT_NUMBER] = measurements.get_measurement(table_type, M_NUMBER_OBJECT_NUMBER) + extra_data[C_IMAGE_NUMBER] = [extra_data[C_IMAGE_NUMBER]] * len(extra_data[C_OBJECT_NUMBER]) + + for omero_column, (col_type, col_name, _) in zip(table_columns, column_list): + if col_type: + true_name = f"{col_type}_{col_name}" + else: + true_name = col_name + if true_name in extra_data: + value = extra_data[true_name] + elif not measurements.has_current_measurements(col_type, col_name): + LOGGER.warning(f"Column not available: {true_name}") + continue + else: + value = measurements.get_measurement(col_type, col_name) + if isinstance(value, str): + value = [value] + elif isinstance(value, Iterable): + value = list(value) + elif value is None and isinstance(omero_column, omero.grid.DoubleColumn): + # Replace None with NaN + value = [math.nan] + elif value is None and isinstance(omero_column, omero.grid.LongColumn): + # Missing values not supported + value = [-1] + else: + value = [value] + omero_column.values = value + try: + omero_table.addData(table_columns) + except Exception as e: + LOGGER.error("Data upload was unsuccessful", exc_info=True) + raise + LOGGER.info(f"OMERO data uploaded for {table_type}") + + def should_stop_writing_measurements(self): + """All subsequent modules should not write measurements""" + return True + + def ignore_object(self, object_name, strict=False): + """Ignore objects (other than 'Image') if this returns true + + If strict is True, then we ignore objects based on the object selection + """ + if object_name in (EXPERIMENT, NEIGHBORS,): + return True + if strict and self.objects_choice == O_NONE: + return True + if strict and self.objects_choice == O_SELECT and object_name != "Image": + return object_name not in self.objects_list.value + return False + + def ignore_feature( + self, + object_name, + feature_name, + strict=False, + ): + """Return true if we should ignore a feature""" + if ( + self.ignore_object(object_name, strict) + or feature_name.startswith("Number_") + or feature_name.startswith("Description_") + or feature_name.startswith("ModuleError_") + or feature_name.startswith("TimeElapsed_") + or (feature_name.startswith("ExecutionTime_")) + ): + return True + return False + + def get_aggregate_columns(self, pipeline): + """Get object aggregate columns for the PerImage table + + pipeline - the pipeline being run + image_set_list - for cacheing column data + post_group - true if only getting aggregates available post-group, + false for getting aggregates available after run, + None to get all + + returns a tuple: + result[0] - object_name = name of object generating the aggregate + result[1] - feature name + result[2] - aggregation operation + result[3] - column name in Image database + """ + columns = self.get_pipeline_measurement_columns(pipeline) + ob_tables = self.get_object_names(pipeline) + result = [] + for ob_table in ob_tables: + for obname, feature, ftype in columns: + if ( + obname == ob_table + and (not self.ignore_feature(obname, feature)) + and (not agg_ignore_feature(feature)) + ): + feature_name = f"{obname}_{feature}" + # create per_image aggregate column defs + result += [ + (aggname, feature_name, ftype) + for aggname in self.agg_names + ] + return result + + def get_object_names(self, pipeline): + """Get the names of the objects whose measurements are being taken""" + column_defs = self.get_pipeline_measurement_columns(pipeline) + obnames = set([c[0] for c in column_defs]) + # + # In alphabetical order + # + obnames = sorted(obnames) + return [obname for obname in obnames if not self.ignore_object(obname, True) + and obname not in ("Image", EXPERIMENT, NEIGHBORS,)] + + @property + def agg_names(self): + """The list of selected aggregate names""" + return [ + name + for name, setting in ( + (AGG_MEAN, self.wants_agg_mean), + (AGG_MEDIAN, self.wants_agg_median), + (AGG_STD_DEV, self.wants_agg_std_dev), + ) + if setting.value + ] + + def display(self, workspace, figure): + figure.set_subplots((1, 1)) + if workspace.pipeline.test_mode: + figure.subplot_table(0, 0, [["Data not written to database in test mode"]]) + else: + figure.subplot_table( + 0, + 0, + workspace.display_data.columns, + col_labels=workspace.display_data.header, + ) + + def display_post_run(self, workspace, figure): + if not workspace.display_data.columns: + # Nothing to display + return + figure.set_subplots((1, 1)) + figure.subplot_table( + 0, + 0, + workspace.display_data.columns, + col_labels=workspace.display_data.header, + ) + + def get_table_prefix(self): + if self.want_table_prefix.value: + return self.table_prefix.value + return "" + + def get_table_name(self, object_name): + """Return the table name associated with a given object + + object_name - name of object or "Image", "Object" or "Well" + """ + return self.get_table_prefix() + "Per_" + object_name + + def get_pipeline_measurement_columns( + self, pipeline + ): + """Get the measurement columns for this pipeline, possibly cached""" + d = self.get_dictionary() + if D_MEASUREMENT_COLUMNS not in d: + d[D_MEASUREMENT_COLUMNS] = pipeline.get_measurement_columns() + d[D_MEASUREMENT_COLUMNS] = self.filter_measurement_columns( + d[D_MEASUREMENT_COLUMNS] + ) + return d[D_MEASUREMENT_COLUMNS] + + def filter_measurement_columns(self, columns): + """Filter out and properly sort measurement columns""" + # Unlike ExportToDb we also filter out complex columns here, + # since post-group measurements can't easily be added to an OMERO.table + columns = [ + x for x in columns + if not self.ignore_feature(x[0], x[1], strict=True) and len(x) == 3 + ] + + # + # put Image ahead of any other object + # put Number_ObjectNumber ahead of any other column + # + def cmpfn(x, y): + if x[0] != y[0]: + if x[0] == "Image": + return -1 + elif y[0] == "Image": + return 1 + else: + return cellprofiler_core.utilities.legacy.cmp(x[0], y[0]) + if x[1] == M_NUMBER_OBJECT_NUMBER: + return -1 + if y[1] == M_NUMBER_OBJECT_NUMBER: + return 1 + return cellprofiler_core.utilities.legacy.cmp(x[1], y[1]) + + columns = sorted(columns, key=functools.cmp_to_key(cmpfn)) + # + # Remove all but the last duplicate + # + duplicate = [ + c0[0] == c1[0] and c0[1] == c1[1] + for c0, c1 in zip(columns[:-1], columns[1:]) + ] + [False] + columns = [x for x, y in zip(columns, duplicate) if not y] + return columns + + def volumetric(self): + return True diff --git a/active_plugins/omero_helper/__init__.py b/active_plugins/omero_helper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/active_plugins/omero_helper/connect.py b/active_plugins/omero_helper/connect.py new file mode 100644 index 0000000..13c734c --- /dev/null +++ b/active_plugins/omero_helper/connect.py @@ -0,0 +1,223 @@ +import atexit +import os +import logging +import sys +import tempfile + +from cellprofiler_core.preferences import config_read_typed, get_headless, get_temporary_directory +from cellprofiler_core.preferences import get_omero_server, get_omero_port, get_omero_user +import omero +from omero.gateway import BlitzGateway +import omero_user_token + +LOGGER = logging.getLogger(__name__) +OMERO_CREDENTIAL_FILE = os.path.join(get_temporary_directory(), "OMERO_CP.token") + + +def login(e=None, server=None, token_path=None): + # Attempt to connect to the server, first using a token, then via GUI + CREDENTIALS.get_tokens(token_path) + if CREDENTIALS.tokens: + if server is None: + # URL didn't specify which server we want. Just try whichever token is available + server = list(CREDENTIALS.tokens.keys())[0] + connected = CREDENTIALS.try_token(server) + else: + connected = CREDENTIALS.client is not None + if get_headless(): + if connected: + LOGGER.info(f"Connected to {CREDENTIALS.server}") + elif CREDENTIALS.try_temp_token(): + connected = True + LOGGER.info(f"Connected to {CREDENTIALS.server}") + else: + LOGGER.warning("Failed to connect, was user token invalid?") + return connected + else: + from .gui import login_gui, configure_for_safe_shutdown + if not CREDENTIALS.bound: + configure_for_safe_shutdown() + CREDENTIALS.bound = True + login_gui(connected, server=None) + + +def get_temporary_dir(): + temporary_directory = get_temporary_directory() + if not ( + os.path.exists(temporary_directory) and os.access(temporary_directory, os.W_OK) + ): + temporary_directory = tempfile.gettempdir() + return temporary_directory + + +def clear_temporary_file(): + LOGGER.debug("Checking for OMERO credential file to delete") + if os.path.exists(OMERO_CREDENTIAL_FILE): + os.unlink(OMERO_CREDENTIAL_FILE) + LOGGER.debug(f"Cleared {OMERO_CREDENTIAL_FILE}") + + +if not get_headless(): + if os.path.exists(OMERO_CREDENTIAL_FILE): + LOGGER.warning("Existing credential file was found") + # Main GUI process should clear any temporary tokens + atexit.register(clear_temporary_file) + + +class LoginHelper: + """ + This class stores our working set of OMERO credentials and connection objects. + + It behaves as a singleton, so multiple OMERO-using plugins will share credentials. + """ + _instance = None + + def __new__(cls): + # We only allow one instance of this class within CellProfiler + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + self.server = get_omero_server() + self.port = get_omero_port() + self.username = get_omero_user() + self.passwd = "" + self.session_key = None + self.session = None + self.client = None + self.gateway = None + self.container_service = None + self.tokens = {} + # Whether we've already hooked into the main GUI frame + self.bound = False + # Any OMERO browser GUI which is connected to this object + self.browser_window = None + atexit.register(self.shutdown) + + def get_gateway(self): + if self.client is None: + raise Exception("Client connection not initialised") + if self.gateway is None: + LOGGER.debug("Constructing BlitzGateway") + self.gateway = BlitzGateway(client_obj=self.client) + return self.gateway + + def get_tokens(self, path=None): + # Load all tokens from omero_user_token + self.tokens.clear() + # Future versions of omero_user_token may support multiple tokens, so we code with that in mind. + # Check the reader setting which disables tokens. + tokens_enabled = config_read_typed(f"Reader.OMERO.allow_token", bool) + if tokens_enabled is not None and not tokens_enabled: + return + # User tokens sadly default to the home directory. This would override that location. + home_key = "USERPROFILE" if sys.platform == "win32" else "HOME" + py_home = os.environ.get(home_key, "") + if path is not None: + os.environ[home_key] = path + try: + LOGGER.info("Requesting token info") + token = omero_user_token.get_token() + server, port = token[token.find('@') + 1:].split(':') + port = int(port) + LOGGER.info("Connection to {}:{}".format(server, port)) + session_key = token[:token.find('@')] + self.tokens[server] = (server, port, session_key) + except Exception: + LOGGER.error("Failed to get user token", exc_info=True) + if path is not None: + os.environ[home_key] = py_home + + def try_token(self, address): + # Attempt to use an omero token to connect to a specific server + if address not in self.tokens: + LOGGER.error(f"Token {address} not found") + return False + else: + server, port, session_key = self.tokens[address] + return self.login(server=server, port=port, session_key=session_key) + + def create_temp_token(self): + # Store a temporary OMERO token based on our active session + # This allows the workers to use that session in Analysis mode. + if self.client is None: + raise ValueError("Client not initialised, cannot make token") + if os.path.exists(OMERO_CREDENTIAL_FILE): + LOGGER.warning(f"Token already exists at {OMERO_CREDENTIAL_FILE}, overwriting") + os.unlink(OMERO_CREDENTIAL_FILE) + try: + token = f"{self.session_key}@{self.server}:{self.port}" + with open(OMERO_CREDENTIAL_FILE, 'w') as token_file: + token_file.write(token) + LOGGER.debug(f"Made temp token for {self.server}") + except: + LOGGER.error("Unable to write temporary token", exc_info=True) + + def try_temp_token(self): + # Look for and attempt to connect to OMERO using a temporary token. + if not os.path.exists(OMERO_CREDENTIAL_FILE): + LOGGER.error(f"No temporary OMERO token found. Cannot connect to server.") + return False + with open(OMERO_CREDENTIAL_FILE, 'r') as token_path: + token = token_path.read().strip() + server, port = token[token.find('@') + 1:].split(':') + port = int(port) + session_key = token[:token.find('@')] + LOGGER.info(f"Using connection details for {self.server}") + return self.login(server=server, port=port, session_key=session_key) + + def login(self, server=None, port=None, user=None, passwd=None, session_key=None): + # Attempt to connect to the server using provided connection credentials + self.client = omero.client(host=server, port=port) + if session_key is not None: + try: + self.session = self.client.joinSession(session_key) + self.client.enableKeepAlive(60) + self.session.detachOnDestroy() + self.server = server + self.port = port + self.session_key = session_key + except Exception as e: + LOGGER.error(f"Failed to join session, token may have expired: {e}") + self.client = None + self.session = None + return False + elif user is not None: + try: + self.session = self.client.createSession( + username=user, password=passwd) + self.client.enableKeepAlive(60) + self.session.detachOnDestroy() + self.session_key = self.client.getSessionId() + self.server = server + self.port = port + self.username = user + self.passwd = passwd + except Exception as e: + LOGGER.error(f"Failed to create session: {e}") + self.client = None + self.session = None + return False + else: + self.client = None + self.session = None + raise Exception( + "Not enough details to create a server connection.") + self.container_service = self.session.getContainerService() + return True + + def handle_exit(self, e): + self.shutdown() + e.Skip() + + def shutdown(self): + # Disconnect from the server + if self.client is not None: + try: + self.client.closeSession() + except Exception as e: + LOGGER.error("Failed to close OMERO session - ", e) + + +CREDENTIALS = LoginHelper() diff --git a/active_plugins/omero_helper/gui.py b/active_plugins/omero_helper/gui.py new file mode 100644 index 0000000..a3e7785 --- /dev/null +++ b/active_plugins/omero_helper/gui.py @@ -0,0 +1,804 @@ +import base64 +import functools +import io +import logging +import os +import queue +import string +import threading +import time + +import requests +import wx +import cellprofiler.gui.plugins_menu +from cellprofiler_core.preferences import config_read_typed, config_write_typed, \ + set_omero_server, set_omero_port, set_omero_user + +from .connect import CREDENTIALS, login + +LOGGER = logging.getLogger(__name__) + + +def get_display_server(): + # Should we display the 'connection successful' message after using a token? + return config_read_typed(f"Reader.OMERO.show_server", bool) + + +def set_display_server(value): + config_write_typed(f"Reader.OMERO.show_server", value, key_type=bool) + + +def login_gui(connected, server=None): + # Login via GUI or display a prompt notifying that we're already connected + if connected: + from cellprofiler.gui.errordialog import show_warning + show_warning("Connected to OMERO", + f"A token was found and used to connect to the OMERO server at {CREDENTIALS.server}", + get_display_server, + set_display_server) + return + show_login_dlg(server=server) + + +def show_login_dlg(e=None, server=None): + # Show the login GUI + app = wx.GetApp() + frame = app.GetTopWindow() + with OmeroLoginDlg(frame, title="Login to OMERO", server=server) as dlg: + dlg.ShowModal() + if CREDENTIALS.client is not None: + CREDENTIALS.create_temp_token() + + +def browse(e): + # Show the browser dialog + if CREDENTIALS.client is None: + login() + app = wx.GetApp() + frame = app.GetTopWindow() + # Only allow a single instance, raise the window if it already exists. + if CREDENTIALS.browser_window is None: + CREDENTIALS.browser_window = OmeroBrowseDlg(frame, title=f"Browse OMERO: {CREDENTIALS.server}") + CREDENTIALS.browser_window.Show() + else: + CREDENTIALS.browser_window.Raise() + + +def inject_plugin_menu_entries(): + # Add plugin menu entries to the main CellProfiler GUI + cellprofiler.gui.plugins_menu.PLUGIN_MENU_ENTRIES.extend([ + (login, wx.NewId(), "Connect to OMERO", "Establish an OMERO connection"), + (show_login_dlg, wx.NewId(), "Connect to OMERO (no token)", "Establish an OMERO connection," + " but without using user tokens"), + (browse, wx.NewId(), "Browse OMERO for images", "Browse an OMERO server and add images to the pipeline") + ]) + + +def configure_for_safe_shutdown(): + # When GUI is running we need to capture wx exit events and close the OMERO connection + app = wx.GetApp() + frame = app.GetTopWindow() + frame.Bind(wx.EVT_CLOSE, CREDENTIALS.handle_exit) + + +class OmeroLoginDlg(wx.Dialog): + """ + A dialog pane to provide and use OMERO login credentials. + """ + def __init__(self, *args, token=True, server=None, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + self.credentials = CREDENTIALS + self.token = token + self.SetSizer(wx.BoxSizer(wx.VERTICAL)) + if server is None: + server = self.credentials.server or "" + sizer = wx.BoxSizer(wx.VERTICAL) + self.Sizer.Add(sizer, 1, wx.EXPAND | wx.ALL, 6) + sub_sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(sub_sizer, 0, wx.EXPAND) + + max_width = 0 + max_height = 0 + for label in ("Server:", "Port:", "Username:", "Password:"): + w, h, _, _ = self.GetFullTextExtent(label) + max_width = max(w, max_width) + max_height = max(h, max_height) + + # Add extra padding + lsize = wx.Size(max_width + 5, max_height) + sub_sizer.Add( + wx.StaticText(self, label="Server:", size=lsize), + 0, wx.ALIGN_CENTER_VERTICAL) + self.omero_server_ctrl = wx.TextCtrl(self, value=server) + sub_sizer.Add(self.omero_server_ctrl, 1, wx.EXPAND) + + sizer.AddSpacer(5) + sub_sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(sub_sizer, 0, wx.EXPAND) + sub_sizer.Add( + wx.StaticText(self, label="Port:", size=lsize), + 0, wx.ALIGN_CENTER_VERTICAL) + self.omero_port_ctrl = wx.lib.intctrl.IntCtrl(self, value=self.credentials.port) + sub_sizer.Add(self.omero_port_ctrl, 1, wx.EXPAND) + + sizer.AddSpacer(5) + sub_sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(sub_sizer, 0, wx.EXPAND) + sub_sizer.Add( + wx.StaticText(self, label="User:", size=lsize), + 0, wx.ALIGN_CENTER_VERTICAL) + self.omero_user_ctrl = wx.TextCtrl(self, value=self.credentials.username or "") + sub_sizer.Add(self.omero_user_ctrl, 1, wx.EXPAND) + + sizer.AddSpacer(5) + sub_sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(sub_sizer, 0, wx.EXPAND) + sub_sizer.Add( + wx.StaticText(self, label="Password:", size=lsize), + 0, wx.ALIGN_CENTER_VERTICAL) + self.omero_password_ctrl = wx.TextCtrl(self, value="", style=wx.TE_PASSWORD | wx.TE_PROCESS_ENTER) + self.omero_password_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_connect_pressed) + if self.credentials.username is not None: + self.omero_password_ctrl.SetFocus() + sub_sizer.Add(self.omero_password_ctrl, 1, wx.EXPAND) + + sizer.AddSpacer(5) + sub_sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(sub_sizer, 0, wx.EXPAND) + connect_button = wx.Button(self, label="Connect") + connect_button.Bind(wx.EVT_BUTTON, self.on_connect_pressed) + sub_sizer.Add(connect_button, 0, wx.EXPAND) + sub_sizer.AddSpacer(5) + + self.message_ctrl = wx.StaticText(self, label="Not connected") + sub_sizer.Add(self.message_ctrl, 1, wx.EXPAND) + + self.token_button = wx.Button(self, label="Set Token") + self.token_button.Bind(wx.EVT_BUTTON, self.on_set_pressed) + self.token_button.Disable() + self.token_button.SetToolTip("Use these credentials to set a long-lasting token for automatic login") + sub_sizer.Add(self.token_button, 0, wx.EXPAND) + sub_sizer.AddSpacer(5) + + button_sizer = wx.StdDialogButtonSizer() + self.Sizer.Add(button_sizer, 0, wx.EXPAND) + + cancel_button = wx.Button(self, wx.ID_CANCEL) + button_sizer.AddButton(cancel_button) + cancel_button.Bind(wx.EVT_BUTTON, self.on_cancel) + + self.ok_button = wx.Button(self, wx.ID_OK) + button_sizer.AddButton(self.ok_button) + self.ok_button.Bind(wx.EVT_BUTTON, self.on_ok) + self.ok_button.Enable(False) + button_sizer.Realize() + + self.omero_password_ctrl.Bind(wx.EVT_TEXT, self.mark_dirty) + self.omero_port_ctrl.Bind(wx.EVT_TEXT, self.mark_dirty) + self.omero_server_ctrl.Bind(wx.EVT_TEXT, self.mark_dirty) + self.omero_user_ctrl.Bind(wx.EVT_TEXT, self.mark_dirty) + self.Layout() + + def mark_dirty(self, event): + if self.ok_button.IsEnabled(): + self.ok_button.Enable(False) + self.message_ctrl.Label = "Please connect with your new credentials" + self.message_ctrl.ForegroundColour = "black" + + def on_connect_pressed(self, event): + if self.credentials.client is not None and self.credentials.server == self.omero_server_ctrl.GetValue(): + # Already connected, accept another 'Connect' command as an ok to close + self.EndModal(wx.OK) + self.connect() + + def on_set_pressed(self, event): + if self.credentials.client is None: + return + import omero_user_token + token_path = omero_user_token.assert_and_get_token_path() + if os.path.exists(token_path): + dlg2 = wx.MessageDialog(self, + "Existing omero_user_token will be overwritten. Proceed?", + "Overwrite existing token?", + wx.YES_NO | wx.CANCEL | wx.ICON_WARNING) + result = dlg2.ShowModal() + if result != wx.ID_YES: + LOGGER.debug("Cancelled") + return + token = omero_user_token.setter( + self.credentials.server, + self.credentials.port, + self.credentials.username, + self.credentials.passwd, + -1) + if token: + LOGGER.info("Set OMERO user token") + self.message_ctrl.Label = "Connected. Token Set!" + self.message_ctrl.ForegroundColour = "forest green" + else: + LOGGER.error("Failed to set OMERO user token") + self.message_ctrl.Label = "Failed to set token." + self.message_ctrl.ForegroundColour = "red" + self.message_ctrl.Refresh() + + def connect(self): + try: + server = self.omero_server_ctrl.GetValue() + port = self.omero_port_ctrl.GetValue() + user = self.omero_user_ctrl.GetValue() + passwd = self.omero_password_ctrl.GetValue() + except: + self.message_ctrl.Label = ( + "The port number must be an integer between 0 and 65535 (try 4064)" + ) + self.message_ctrl.ForegroundColour = "red" + self.message_ctrl.Refresh() + return False + self.message_ctrl.ForegroundColour = "black" + self.message_ctrl.Label = "Connecting..." + self.message_ctrl.Refresh() + # Allow UI to update before connecting + wx.Yield() + success = self.credentials.login(server, port, user, passwd) + if success: + self.message_ctrl.Label = "Connected" + self.message_ctrl.ForegroundColour = "forest green" + self.token_button.Enable() + self.message_ctrl.Refresh() + set_omero_server(server) + set_omero_port(port) + set_omero_user(user) + self.ok_button.Enable(True) + return True + else: + self.message_ctrl.Label = "Failed to log onto server" + self.message_ctrl.ForegroundColour = "red" + self.message_ctrl.Refresh() + self.token_button.Disable() + return False + + def on_cancel(self, event): + self.EndModal(wx.CANCEL) + + def on_ok(self, event): + self.EndModal(wx.OK) + + +class OmeroBrowseDlg(wx.Dialog): + """ + An OMERO server browser intended for browsing images and adding them to the main file list + """ + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, + style=wx.RESIZE_BORDER | wx.CAPTION | wx.CLOSE_BOX, + size=(900, 600), + **kwargs) + self.credentials = CREDENTIALS + self.admin_service = self.credentials.session.getAdminService() + self.url_loader = self.Parent.pipeline.add_urls + + self.Bind(wx.EVT_CLOSE, self.close_browser) + + ec = self.admin_service.getEventContext() + # Exclude sys groups + self.groups = [self.admin_service.getGroup(v) for v in ec.memberOfGroups if v > 1] + self.group_names = [group.name.val for group in self.groups] + self.current_group = self.groups[0].id.getValue() + self.users_in_group = {'All Members': -1} + self.users_in_group.update({ + x.omeName.val: x.id.val for x in self.groups[0].linkedExperimenterList() + }) + self.current_user = -1 + self.levels = {'projects': 'datasets', + 'datasets': 'images', + 'screens': 'plates', + 'plates': 'wells', + 'orphaned': 'images' + } + + splitter = wx.SplitterWindow(self, -1, style=wx.SP_BORDER) + self.browse_controls = wx.Panel(splitter, -1) + b = wx.BoxSizer(wx.VERTICAL) + + self.groups_box = wx.Choice(self.browse_controls, choices=self.group_names) + self.groups_box.SetSelection(0) + self.groups_box.Bind(wx.EVT_CHOICE, self.switch_group) + + self.members_box = wx.Choice(self.browse_controls, choices=list(self.users_in_group.keys())) + self.members_box.SetSelection(0) + self.members_box.Bind(wx.EVT_CHOICE, self.switch_member) + + b.Add(self.groups_box, 0, wx.EXPAND) + b.Add(self.members_box, 0, wx.EXPAND) + self.container = self.credentials.session.getContainerService() + + self.tree = wx.TreeCtrl(self.browse_controls, style=wx.TR_HAS_BUTTONS | wx.TR_HIDE_ROOT) + image_list = wx.ImageList(16, 13) + image_data = { + 'projects': 'iVBORw0KGgoAAAANSUhEUgAAABAAAAANCAYAAACgu+4kAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA5NpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpDNDk5ODU0N0U5MjA2ODExODhDNkJBNzRDM0U2QkE2NyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpFNjZDNDcxNzc5NEUxMUUxOTY2OEJEQjhGOUExQ0Y3RCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpFNjZDNDcxNjc5NEUxMUUxOTY2OEJEQjhGOUExQ0Y3RCIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2ICgxMy4wIDIwMTIwMzA1Lm0uNDE1IDIwMTIvMDMvMDU6MjE6MDA6MDApICAoTWFjaW50b3NoKSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkM1NzAxRDEzMjkyMTY4MTE4OEM2QkE3NEMzRTZCQTY3IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkM0OTk4NTQ3RTkyMDY4MTE4OEM2QkE3NEMzRTZCQTY3Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+vd9MhwAAALhJREFUeNpi/P//P0NEXsd/BhxgxaQKRgY8gAXGMNbVwpA8e/kaAyHAGJhWe5iNnctGVkoaQ/Lxs6cMv35+w6f/CMvfP39svDytscrqaijgtX3t5u02IAMYXrx5z0AOAOll+fPnF8Ov37/IMgCkl+XP799Af5JpAFAv0IBfDD9//STTAKALfgMJcl0A0gvxwi8KvPAXFIhkGvAXHIjAqPjx4zuZsQCMxn9//my9eOaEFQN54ChAgAEAzRBnWnEZWFQAAAAASUVORK5CYII=', + 'datasets': 'iVBORw0KGgoAAAANSUhEUgAAABAAAAANCAYAAACgu+4kAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA5NpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpDNDk5ODU0N0U5MjA2ODExODhDNkJBNzRDM0U2QkE2NyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpFNjZGMDA2ODc5NEUxMUUxOTY2OEJEQjhGOUExQ0Y3RCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpFNjZDNDcxQTc5NEUxMUUxOTY2OEJEQjhGOUExQ0Y3RCIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2ICgxMy4wIDIwMTIwMzA1Lm0uNDE1IDIwMTIvMDMvMDU6MjE6MDA6MDApICAoTWFjaW50b3NoKSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkM1NzAxRDEzMjkyMTY4MTE4OEM2QkE3NEMzRTZCQTY3IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkM0OTk4NTQ3RTkyMDY4MTE4OEM2QkE3NEMzRTZCQTY3Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+l9tKdwAAAKZJREFUeNpi/P//P0NUm+d/BhxgWdV2RgY8gAXGMNMwwpA8deMcAyHAEljsdJhTmJ3h1YfnWBUA5f/j0X+E5d+//zYhrv5YZU108du+cNlKG6AB/xjefn7FQA4A6WX59/cfw5/ff8gz4C/IAKApf/78pswFv3/9Jt8Ff8FeIM+AvzAv/KbUC/8oC8T/DN9//iLTBf8ZWP7/+7f18MELVgzkgaMAAQYAgLlmT8qQW/sAAAAASUVORK5CYII=', + 'screens': 'iVBORw0KGgoAAAANSUhEUgAAABEAAAANCAYAAABPeYUaAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA5NpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpDNDk5ODU0N0U5MjA2ODExODhDNkJBNzRDM0U2QkE2NyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo5NkU0QUUzNjc4NEExMUUxOTY2OEJEQjhGOUExQ0Y3RCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo5NkU0QUUzNTc4NEExMUUxOTY2OEJEQjhGOUExQ0Y3RCIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2ICgxMy4wIDIwMTIwMzA1Lm0uNDE1IDIwMTIvMDMvMDU6MjE6MDA6MDApICAoTWFjaW50b3NoKSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkM1NzAxRDEzMjkyMTY4MTE4OEM2QkE3NEMzRTZCQTY3IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkM0OTk4NTQ3RTkyMDY4MTE4OEM2QkE3NEMzRTZCQTY3Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+wkwZRwAAAQxJREFUeNpi/P//PwMjIyODqbHhfwYc4PTZ84y45ED6WZAFPFxdwQYig+27djEQAowgk6wtLcGu4GBnY0C38vvPX3gNOHr8OCPcJaHh4QwsLCwYiv78+YNV87+/fxnWrlkDZsN1PXr0iGHPnj1wRS4uLnj5jg4OcDYTjPH7928w3WxmhcJ3zmtC4Zc2mUH4SC6EG/LrF8TvtaeOofD3TqpD4XfXnULho3gHJOjt7Q2XePHiBV7+s6dPsRuydetWuISuri5evra2Nm7vVKUZoPDTratR+EZV1RjewTCkbdYFFP7Mo60o/HNtrSgBjeEdMzMzuMRToJ/x8Z88eYJpyKcPH8AYGRDiwwBAgAEAvXKdXsBF6t8AAAAASUVORK5CYII=', + 'plates': 'iVBORw0KGgoAAAANSUhEUgAAAA8AAAANCAYAAAB2HjRBAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAASBJREFUeNpiZAACRVXNhnfv3tX//v2bgQjwAIgDv316d4FR38hM4MHDh++trSwZ+Pn4COr8+OkTw4GDhx4ANSuygARANj59+ozhxNFDKAplFVQYHj+4gyEGBAogguniuVMfkCUf7Z4IxsjgyqOJYIwOWGCM////g+mfj68woIt9/Ikphqr53z8wrZo0mwFdzFoVUwxF8z+giRbWDijOevjwAVYxrDafOHoARaGElBxWMRhgQvgF4px98x+BMbLYPSD/HpoYqrP/QQLi2Y2fDOhi37CIoYU2xMSYTlUGdDEdLGIgwAgiNHUM/r9//55BR1sbxX+PHj1kkJOTR43zq1cZBAUFGa5fuQDWy3D69Jn7IAO4+IQIYpA6kHqQPoAAAwCQE6mYLjTwJwAAAABJRU5ErkJggg==' + } + self.image_codes = {} + for name, dat in image_data.items(): + decodedImgData = base64.b64decode(dat) + bio = io.BytesIO(decodedImgData) + img = wx.Image(bio) + img = img.Scale(16, 13) + self.image_codes[name] = image_list.Add(img.ConvertToBitmap()) + self.tree.AssignImageList(image_list) + + self.tree.Bind(wx.EVT_TREE_ITEM_EXPANDING, self.fetch_children) + self.tree.Bind(wx.EVT_TREE_SEL_CHANGING, self.select_tree) + self.tree.Bind(wx.EVT_TREE_SEL_CHANGED, self.update_thumbnails) + self.tree.Bind(wx.EVT_TREE_BEGIN_DRAG, self.process_drag) + + data = self.fetch_containers() + + self.populate_tree(data) + b.Add(self.tree, 1, wx.EXPAND) + + self.browse_controls.SetSizer(b) + + self.image_controls = wx.Panel(splitter, -1) + + vert_sizer = wx.BoxSizer(wx.VERTICAL) + + self.tile_panel = TilePanel(self.image_controls, 1) + self.tile_panel.url_loader = self.url_loader + + self.tiler_sizer = wx.WrapSizer(wx.HORIZONTAL) + self.tile_panel.SetSizer(self.tiler_sizer) + self.tile_panel.SetScrollbars(0, 20, 0, 20) + + self.update_thumbnails() + vert_sizer.Add(self.tile_panel, wx.EXPAND) + + add_button = wx.Button(self.image_controls, wx.NewId(), "Add to file list") + add_button.Bind(wx.EVT_BUTTON, self.add_selected_to_pipeline) + vert_sizer.Add(add_button, 0, wx.ALIGN_RIGHT | wx.ALL, 5) + self.image_controls.SetSizer(vert_sizer) + splitter.SplitVertically(self.browse_controls, self.image_controls, 300) + + self.Layout() + + def close_browser(self, event): + # Disconnect the browser window from the login helper + self.credentials.browser_window = None + # Tell the thumbnail generator to shut down + self.tile_panel.active = False + event.Skip() + + def add_selected_to_pipeline(self, e=None): + # Add selected images to the pipeline as URLs + displayed = self.tile_panel.GetChildren() + all_urls = [] + selected_urls = [] + for item in displayed: + if isinstance(item, ImagePanel): + all_urls.append(item.url) + if item.selected: + selected_urls.append(item.url) + if selected_urls: + self.url_loader(selected_urls) + else: + self.url_loader(all_urls) + + def process_drag(self, event): + # CellProfiler's file list uses a custom handler. + # This will mimic dropping actual files onto it. + data = wx.FileDataObject() + for file_url in self.fetch_file_list_from_tree(event): + data.AddFile(file_url) + drop_src = wx.DropSource(self) + drop_src.SetData(data) + drop_src.DoDragDrop(wx.Drag_CopyOnly) + + def fetch_file_list_from_tree(self, event): + # Generate a list of OMERO URLs when the user tries to drag an entry. + files = [] + + def recurse_for_images(tree_id): + # Search the tree for images and add to a list of URLs + if not self.tree.IsExpanded(tree_id): + self.tree.Expand(tree_id) + data = self.tree.GetItemData(tree_id) + item_type = data['type'] + if item_type == 'images': + files.append(f"https://{self.credentials.server}/webclient/?show=image-{data['id']}") + elif 'images' in data: + for omero_id, _ in data['images'].items(): + files.append(f"https://{self.credentials.server}/webclient/?show=image-{omero_id}") + else: + child_id, cookie = self.tree.GetFirstChild(tree_id) + while child_id.IsOk(): + recurse_for_images(child_id) + child_id, cookie = self.tree.GetNextChild(tree_id, cookie) + + recurse_for_images(event.GetItem()) + return files + + def select_tree(self, event): + # The tree is fetched as it's being expanded. Clicking on an object needs to trigger child expansion. + target_id = event.GetItem() + self.tree.Expand(target_id) + + def switch_group(self, e=None): + # Change OMERO group + new_group = self.groups_box.GetCurrentSelection() + self.current_group = self.groups[new_group].id.getValue() + self.current_user = -1 + self.refresh_group_members() + data = self.fetch_containers() + self.populate_tree(data) + + def switch_member(self, e=None): + # Change OMERO user filter + new_member = self.members_box.GetStringSelection() + self.current_user = self.users_in_group.get(new_member, -1) + data = self.fetch_containers() + self.populate_tree(data) + + def refresh_group_members(self): + # Update the available user list when the group changes + self.users_in_group = {'All Members': -1} + group = self.groups[self.groups_box.GetCurrentSelection()] + self.users_in_group.update({ + x.omeName.val: x.id.val for x in group.linkedExperimenterList() + }) + self.members_box.Clear() + self.members_box.AppendItems(list(self.users_in_group.keys())) + self.members_box.SetSelection(0) + + def fetch_children(self, event): + # Load the next level in the tree for a target object. + target_id = event.GetItem() + if self.tree.GetChildrenCount(target_id, recursively=False) > 0: + # Already loaded + return + data = self.tree.GetItemData(target_id) + subject_type = data['type'] + target_type = self.levels.get(subject_type, None) + if target_type is None: + # We're at the bottom level already + return + subject = data['id'] + if subject == -1: + sub_str = "orphaned=true&" + else: + sub_str = f"id={subject}&" + if target_type == 'wells': + url = f"https://{self.credentials.server}/api/v0/m/plates/{subject}/wells/?bsession={self.credentials.session_key}" + else: + url = f"https://{self.credentials.server}/webclient/api/{target_type}/?{sub_str}experimenter_id=-1&page=0&group={self.current_group}&bsession={self.credentials.session_key}" + LOGGER.debug(f"Fetching {url}") + try: + result = requests.get(url, timeout=15) + except requests.exceptions.Timeout: + LOGGER.error("Server request timed out") + return + result = result.json() + if 'images' in result: + image_map = {entry['id']: entry['name'] for entry in result['images']} + data['images'] = image_map + self.tree.SetItemData(target_id, data) + if 'meta' in result: + # This is the plates API + self.populate_tree_screen(result, target_id) + result = result['data'] + else: + self.populate_tree(result, target_id) + + def fetch_containers(self): + # Grab the base project/dataset structure for the tree view. + url = f"https://{self.credentials.server}/webclient/api/containers/?id={self.current_user}&page=0&group={self.current_group}&bsession={self.credentials.session_key}" + try: + data = requests.get(url, timeout=5) + except requests.exceptions.Timeout: + LOGGER.error("Server request timed out") + return {} + data.raise_for_status() + return data.json() + + def update_thumbnails(self, event=None): + # Show image previews when objects in the tree are clicked on. + self.tiler_sizer.Clear(delete_windows=True) + # Empty out any pending tile thumbnails + with self.tile_panel.thumbnail_queue.mutex: + self.tile_panel.thumbnail_queue.queue.clear() + if not event: + return + target_id = event.GetItem() + item_data = self.tree.GetItemData(target_id) + if item_data.get('type', None) == 'images': + # We're displaying a single image + image_id = item_data['id'] + img_name = item_data['name'] + tile = ImagePanel(self.tile_panel, image_id, img_name, self.credentials.server, size=450) + tile.selected = True + self.tiler_sizer.Add(tile, 0, wx.ALL, 5) + else: + # We're displaying a series of images + image_targets = item_data.get('images', {}) + if not image_targets: + return + for image_id, image_name in image_targets.items(): + tile = ImagePanel(self.tile_panel, image_id, image_name, self.credentials.server) + self.tiler_sizer.Add(tile, 0, wx.ALL, 5) + self.tiler_sizer.Layout() + self.image_controls.Layout() + self.image_controls.Refresh() + + def populate_tree(self, data, parent=None): + # Build the tree view + if parent is None: + self.tree.DeleteAllItems() + parent = self.tree.AddRoot("Server") + for item_type, items in data.items(): + image = self.image_codes.get(item_type, None) + if not isinstance(items, list): + items = [items] + for entry in items: + entry['type'] = item_type + new_id = self.tree.AppendItem(parent, f"{entry['name']}", data=entry) + if image is not None: + self.tree.SetItemImage(new_id, image, wx.TreeItemIcon_Normal) + if entry.get('childCount', 0) > 0 or item_type == 'plates': + self.tree.SetItemHasChildren(new_id) + + def populate_tree_screen(self, data, parent=None): + # Fill the tree data from the screens API + wells = data['data'] + rows = string.ascii_uppercase + for well_dict in wells: + name = f"Well {rows[well_dict['Row']]}{well_dict['Column'] + 1:02}" + well_dict['type'] = 'wells' + well_id = self.tree.AppendItem(parent, name) + well_dict['images'] = {} + for field_dict in well_dict['WellSamples']: + image_data = field_dict['Image'] + image_id = image_data['@id'] + image_name = image_data['Name'] + refined_image_data = { + 'type': 'images', + 'id': image_id, + 'name': image_name + } + well_dict['images'][image_id] = image_name + self.tree.AppendItem(well_id, image_name, data=refined_image_data) + self.tree.SetItemData(well_id, well_dict) + + +class ImagePanel(wx.Panel): + """ + The ImagePanel displays an image's name and a preview thumbnail as a wx.Bitmap. + + Tiles are initialised with a loading icon, call update_thumbnail once image data has arrived for display. + """ + + def __init__(self, parent, omero_id, name, server, size=128): + """ + parent - parent window to the wx.Panel + omero_id - OMERO id of the image + name - name to display + server - the server the image lives on + size - int dimension of the thumbnail to display + """ + self.parent = parent + self.bitmap = None + self.selected = False + self.omero_id = omero_id + self.url = f"https://{server}/webclient/?show=image-{omero_id}" + self.name = name + max_len = int(17 / 128 * size) + if len(name) > max_len: + self.shortname = name[:max_len - 3] + '...' + else: + self.shortname = name + self.size_x = size + self.size_y = size + 30 + wx.Panel.__init__(self, parent, wx.NewId(), size=(self.size_x, self.size_y)) + indicator_size = 64 + self.loading = wx.ActivityIndicator(self, + size=wx.Size(indicator_size, indicator_size), + pos=((self.size_x - indicator_size) // 2, + ((self.size_x - indicator_size) // 2) + 20) + ) + self.loading.Start() + self.parent.thumbnail_queue.put((omero_id, size, self.update_thumbnail)) + self.Bind(wx.EVT_PAINT, self.OnPaint) + self.Bind(wx.EVT_LEFT_DOWN, self.select) + self.Bind(wx.EVT_RIGHT_DOWN, self.right_click) + self.SetClientSize((self.size_x, self.size_y)) + # We need to pass these events up to the parent panel. + self.Bind(wx.EVT_MOTION, self.pass_event) + self.Bind(wx.EVT_LEFT_UP, self.pass_event) + + def select(self, e): + # Mark a panel as selected + self.selected = not self.selected + self.Refresh() + e.StopPropagation() + e.Skip() + + def pass_event(self, e): + # We need to pass mouse events up to the containing TilePanel. + # To do this we need to correct the event position to be relative to the parent. + x, y = e.GetPosition() + w, h = self.GetPosition() + e.SetPosition((x + w, y + h)) + # Now we send the event upwards to be caught by the parent. + e.ResumePropagation(1) + e.Skip() + + def right_click(self, event): + # Show right click menu + popupmenu = wx.Menu() + add_file_item = popupmenu.Append(-1, "Add to file list") + self.Bind(wx.EVT_MENU, self.add_to_pipeline, add_file_item) + add_file_item = popupmenu.Append(-1, "Show in OMERO.web") + self.Bind(wx.EVT_MENU, self.open_in_browser, add_file_item) + # Show menu + self.PopupMenu(popupmenu, event.GetPosition()) + + def add_to_pipeline(self, e): + # Add image to the pipeline + self.parent.url_loader([self.url]) + + def open_in_browser(self, e): + # Open in OMERO.web + wx.LaunchDefaultBrowser(self.url) + + def update_thumbnail(self, bitmap): + # Replace the temporary loading icon with a thumbnail image + if not self.__nonzero__() or self.IsBeingDeleted(): + # Skip update if the tile has already been deleted from the panel + return + if self.loading is not None: + # Remove the loading widget + self.loading.Destroy() + self.bitmap = bitmap + self.Refresh() + + def OnPaint(self, evt): + # Custom paint handler to display image/label/selection marker. + dc = wx.PaintDC(self) + dc.Clear() + if self.bitmap is not None: + dc.DrawBitmap(self.bitmap, (self.size_x - self.bitmap.Width) // 2, + ((self.size_x - self.bitmap.Height) // 2) + 20) + rect = wx.Rect(0, 0, self.size_x, self.size_x + 20) + dc.DrawLabel(self.shortname, rect, alignment=wx.ALIGN_CENTER_HORIZONTAL | wx.ALIGN_TOP) + if self.selected: + dc.SetPen(wx.Pen("SLATE BLUE", 3, style=wx.PENSTYLE_SOLID)) + else: + dc.SetPen(wx.Pen("GREY", 1, style=wx.PENSTYLE_SOLID)) + dc.SetBrush(wx.Brush("BLACK", wx.TRANSPARENT)) + dc.DrawRectangle(rect) + return dc + + +class TilePanel(wx.ScrolledWindow): + """ + A scrollable window which will contain image panels and allow selection of them by drawing a rectangle. + """ + + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + self.select_source = None + self.select_box = None + + self.credentials = CREDENTIALS + + self.active = True + self.thumbnail_queue = queue.Queue() + self.thumbnail_thread = threading.Thread(name="ThumbnailProvider", target=self.thumbnail_loader, daemon=True) + self.thumbnail_thread.start() + + self.Bind(wx.EVT_MOTION, self.OnMotion) + self.Bind(wx.EVT_PAINT, self.OnPaint) + self.Bind(wx.EVT_LEFT_UP, self.on_release) + + def thumbnail_loader(self): + # Spin and monitor queue + # Jobs will arrive as tuples of (omero id, thumbnail size, tile update function). + chunk_size = 10 + LOGGER.debug("Starting thumbnail loader") + size = 0 + callback_map = {} + while self.active: + if self.thumbnail_queue.empty(): + time.sleep(0.1) + for _ in range(chunk_size): + if not self.thumbnail_queue.empty(): + omero_id, size, callback = self.thumbnail_queue.get() + callback_map[str(omero_id)] = callback + else: + break + if callback_map: + ids_to_fetch = list(callback_map.keys()) + ids_str = '&id='.join(ids_to_fetch) + url = f"https://{self.credentials.server}/webclient/get_thumbnails/{size}/?&bsession={self.credentials.session_key}&id={ids_str}" + LOGGER.debug(f"Fetching {url}") + result = {} + try: + data = requests.get(url, timeout=10) + if data.status_code != 200: + LOGGER.warning(f"Server error: {data.status_code} - {data.reason}") + else: + result.update(data.json()) + except requests.exceptions.Timeout: + LOGGER.error("URL fetch timed out") + except Exception: + LOGGER.error("Unable to retrieve data", exc_info=True) + for omero_id, callback in callback_map.items(): + image_data = result.get(omero_id, "") + start_data = image_data.find('/9') + if start_data == -1: + LOGGER.info(f"No thumbnail data was returned for image {omero_id}") + img = self.get_error_thumbnail(size) + else: + decoded = base64.b64decode(image_data[start_data:]) + bio = io.BytesIO(decoded) + img = wx.Image(bio) + if not img.IsOk(): + LOGGER.info(f"Thumbnail data was invalid for image {omero_id}") + img = self.get_error_thumbnail(size) + else: + img = img.ConvertToBitmap() + # Update the tile in question. This must be scheduled on the main GUI thread to avoid crashes. + wx.CallAfter(callback, img) + callback_map = {} + + def deselect_all(self): + for child in self.GetChildren(): + if isinstance(child, ImagePanel): + child.selected = False + + def OnMotion(self, evt): + # Handle drag selection + if not evt.LeftIsDown(): + # Not dragging + self.select_source = None + self.select_box = None + return + self.SetFocusIgnoringChildren() + if self.select_source is None: + self.select_source = evt.Position + return + else: + self.select_box = wx.Rect(self.select_source, evt.Position) + if self.select_box.Width < 5 and self.select_box.Height < 5: + # Don't start selecting until a reasonable box size is drawn + return + for child in self.GetChildren(): + if isinstance(child, ImagePanel): + if not evt.ShiftDown(): + child.selected = False + if child.GetRect().Intersects(self.select_box): + child.selected = True + self.Refresh() + + def on_release(self, e): + # Cease dragging + self.select_source = None + self.select_box = None + self.Refresh() + + def OnPaint(self, e): + # Draw selection box. + dc = wx.PaintDC(self) + dc.SetPen(wx.Pen("BLUE", 3, style=wx.PENSTYLE_SHORT_DASH)) + dc.SetBrush(wx.Brush("BLUE", style=wx.TRANSPARENT)) + if self.select_box is not None: + dc.DrawRectangle(self.select_box) + + @functools.lru_cache(maxsize=10) + def get_error_thumbnail(self, size): + # Draw an image with an error icon. Cache the result since we may need the error icon repeatedly. + artist = wx.ArtProvider() + size //= 2 + return artist.GetBitmap(wx.ART_WARNING, size=(size, size)) + +# TODO: Paginate well loading diff --git a/active_plugins/omeroreader.py b/active_plugins/omeroreader.py new file mode 100644 index 0000000..64a830d --- /dev/null +++ b/active_plugins/omeroreader.py @@ -0,0 +1,458 @@ +""" +An image reader which connects to OMERO to load data + +# Installation - +Easy mode - clone the plugins repository and point your CellProfiler plugins folder to this folder. +Navigate to /active_plugins/ and run `pip install -e .[omero]` to install dependencies. + +## Manual Installation + +Add this file plus the `omero_helper` directory into your CellProfiler plugins folder. Install dependencies into +your CellProfiler Python environment. + +## Installing dependencies - +This depends on platform. At the most basic level you'll need the `omero-py` package and the `omero_user_token` package. + +Both should be possible to pip install on Windows. On MacOS, you'll probably have trouble with the zeroc-ice dependency. +omero-py uses an older version and so needs specific wheels. Fortunately we've built some for you. +Macos - https://github.com/glencoesoftware/zeroc-ice-py-macos-x86_64/releases/latest +Linux (Generic) - https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/latest +Ubuntu 22.04 - https://github.com/glencoesoftware/zeroc-ice-py-ubuntu2204-x86_64/releases/latest + +Download the .whl file from whichever is most appropriate and run `pip install `. + +From there pip install omero-py should do the rest. + +You'll also want the `omero_user_token` package to help manage logins (`pip install omero_user_token`). +This allows you to set reusable login tokens for quick reconnection to a server. These tokens are required for using +headless mode/analysis moe. + + +# Usage - +Like the old functionality from <=CP4, connection to OMERO is triggered through a login dialog within the GUI which +should appear automatically when needed. Enter your credentials and hit 'connect'. Once connected you should be able to +load OMERO data into the workspace. + +We've also made a "Connect to OMERO" menu option available in the new Plugins menu, in case you ever need to forcibly +open that window again (e.g. changing server). + +To get OMERO data into a pipeline, you can construct a file list in the special URL format `omero:iid=`. +E.g. "omero:iid=4345" + +Alternatively, direct URLs pointing to an image can be provided. +e.g. https://omero.mywebsite.com/webclient/?show=image-1234 +These can be obtained in OMERO-web by selecting an image and pressing the link button in the top right corner +of the right side panel. + +To get these into the CellProfiler GUI, there are a few options. Previously this was primarily achieved by using +*File->Import->File List* to load a text file containing one image per line. A LoadData CSV can also be used. +As of CP5 it is also now possible to copy and paste text (e.g. URLs) directly into the file list in the Images module. + +In the Plugins menu you'll also find an option to browse an OMERO server for images and add them to your file list. +This provides an alternative method for constructing your file list. Images will be added to the list in the +OMERO URL format. + +# Tokens - +omero_user_token creates a long-lasting session token based on your login credentials, which can then be reconnected to +at a later time. The CellProfiler plugin will detect and use these tokens to connect to a server automatically. Use the +`Connect to OMERO (No token)` option in the Plugins menu if you need to switch servers. + +Within the connect dialog you'll find a new 'Set Token' button which allows you to create these tokens after making a +successful connection. These tokens are important when working in headless mode, but also mean that you no longer +need to enter your credentials each time you login via the GUI. Current omero_user_token builds support a single token +at a time, which will be stored in your user home directory. + +# Working with data - +Unlike previous iterations of this integration, the CP5 plugin has full support for channel and plane indexing. +The previous reader misbehaved in that it would only load the first channel from OMERO if greyscale is requested. +In this version all channels will be returned, so you must declare a colour image in NamesAndTypes when loading one. + +On the plus side, you can now use the 'Extract metadata' option in the Images module to split the C, Z and T axes +into individual planes. Remember to disable the "Filter to images only" option in the Images module, since URLs do +not pass this filter. + +Lastly, with regards to connections, you can only connect to a single server at a time. Opening the connect dialog and +dialling in will replace any existing connection which you had active. This iteration of the plugin will keep +server connections from timing out while CellProfiler is running, though you may need to reconnect if the PC +goes to sleep. +""" +import collections +import urllib.parse + +from struct import unpack + +import numpy + +from cellprofiler_core.preferences import get_headless +from cellprofiler_core.constants.image import MD_SIZE_S, MD_SIZE_C, MD_SIZE_Z, MD_SIZE_T, \ + MD_SIZE_Y, MD_SIZE_X, MD_SERIES_NAME +from cellprofiler_core.constants.image import PASSTHROUGH_SCHEMES +from cellprofiler_core.reader import Reader + +import logging +import re + +from omero_helper.connect import login, CREDENTIALS + +if not get_headless(): + # Load the GUI components and add the plugin menu options + from omero_helper.gui import inject_plugin_menu_entries + inject_plugin_menu_entries() + +# Isolates image numbers from OMERO URLs +REGEX_INDEX_FROM_FILE_NAME = re.compile(r'\?show=image-(\d+)') + +# Inject omero as a URI scheme which CellProfiler should accept as an image entry. +PASSTHROUGH_SCHEMES.append('omero') + +LOGGER = logging.getLogger(__name__) + +# Maps OMERO pixel types to numpy +PIXEL_TYPES = { + "int8": ['b', numpy.int8, (-128, 127)], + "uint8": ['B', numpy.uint8, (0, 255)], + "int16": ['h', numpy.int16, (-32768, 32767)], + "uint16": ['H', numpy.uint16, (0, 65535)], + "int32": ['i', numpy.int32, (-2147483648, 2147483647)], + "uint32": ['I', numpy.uint32, (0, 4294967295)], + "float": ['f', numpy.float32, (0, 1)], + "double": ['d', numpy.float64, (0, 1)] +} + + +class OMEROReader(Reader): + """ + Reads images from an OMERO server. + """ + reader_name = "OMERO" + variable_revision_number = 1 + supported_filetypes = {} + supported_schemes = {'omero', 'http', 'https'} + + def __init__(self, image_file): + self.login = CREDENTIALS + self.image_id = None + self.server = None + self.omero_image = None + self.pixels = None + self.width = None + self.height = None + self.context = {'omero.group': '-1'} + super().__init__(image_file) + + def __del__(self): + self.close() + + def confirm_connection(self): + # Verify that we're able to connect to a server + if self.login.client is None: + if get_headless(): + connected = login(server=self.server) + if connected: + return True + else: + raise ValueError("No OMERO connection established") + else: + login(server=self.server) + if self.login.client is None: + raise ValueError("Connection failed") + + def init_reader(self): + # Setup the reader + if self.omero_image is not None: + # We're already connected and have fetched the image pointer + return True + if self.file.scheme == "omero": + self.image_id = int(self.file.url[10:]) + else: + matches = REGEX_INDEX_FROM_FILE_NAME.findall(self.file.url) + if not matches: + raise ValueError("URL may not be from OMERO?") + self.image_id = int(matches[0]) + self.server = urllib.parse.urlparse(self.file.url).hostname + + # Check if session object already exists + self.confirm_connection() + + LOGGER.debug("Initializing OmeroReader for Image id: %s" % self.image_id) + # Get image object from the server + try: + self.omero_image = self.login.container_service.getImages( + "Image", [self.image_id], None, self.context)[0] + except: + message = "Image Id: %s not found on the server." % self.image_id + LOGGER.error(message, exc_info=True) + raise Exception(message) + self.pixels = self.omero_image.getPrimaryPixels() + self.width = self.pixels.getSizeX().val + self.height = self.pixels.getSizeY().val + return True + + def read(self, + series=None, + index=None, + c=None, + z=None, + t=None, + rescale=True, + xywh=None, + wants_max_intensity=False, + channel_names=None, + volumetric=False, + ): + """Read a single plane from the image file. + :param c: read from this channel. `None` = read color image if multichannel + or interleaved RGB. + :param z: z-stack index + :param t: time index + :param series: series for ``.flex`` and similar multi-stack formats + :param index: if `None`, fall back to ``zct``, otherwise load the indexed frame + :param rescale: `True` to rescale the intensity scale to 0 and 1; `False` to + return the raw values native to the file. + :param xywh: a (x, y, w, h) tuple + :param wants_max_intensity: if `False`, only return the image; if `True`, + return a tuple of image and max intensity + :param channel_names: provide the channel names for the OME metadata + :param volumetric: Whether we're reading in 3D + """ + self.init_reader() + debug_message = \ + "Reading C: %s, Z: %s, T: %s, series: %s, index: %s, " \ + "channel names: %s, rescale: %s, wants_max_intensity: %s, " \ + "XYWH: %s" % (c, z, t, series, index, channel_names, rescale, + wants_max_intensity, xywh) + if c is None and index is not None: + c = index + LOGGER.debug(debug_message) + message = None + if (t or 0) >= self.pixels.getSizeT().val: + message = "T index %s exceeds sizeT %s" % \ + (t, self.pixels.getSizeT().val) + LOGGER.error(message) + if (c or 0) >= self.pixels.getSizeC().val: + message = "C index %s exceeds sizeC %s" % \ + (c, self.pixels.getSizeC().val) + LOGGER.error(message) + if (z or 0) >= self.pixels.getSizeZ().val: + message = "Z index %s exceeds sizeZ %s" % \ + (z, self.pixels.getSizeZ().val) + LOGGER.error(message) + if message is not None: + raise Exception("Couldn't retrieve a plane from OMERO image.") + tile = None + if xywh is not None: + assert isinstance(xywh, tuple) and len(xywh) == 4, \ + "Invalid XYWH tuple" + tile = xywh + if not volumetric: + numpy_image = self.read_planes(z, c, t, tile) + else: + numpy_image = self.read_planes_volumetric(z, c, t, tile) + pixel_type = self.pixels.getPixelsType().value.val + min_value = PIXEL_TYPES[pixel_type][2][0] + max_value = PIXEL_TYPES[pixel_type][2][1] + LOGGER.debug("Pixel range [%s, %s]" % (min_value, max_value)) + if rescale or pixel_type == 'double': + LOGGER.info("Rescaling image using [%s, %s]" % (min_value, max_value)) + # Note: The result here differs from: + # https://github.com/emilroz/python-bioformats/blob/a60b5c5a5ae018510dd8aa32d53c35083956ae74/bioformats/formatreader.py#L903 + # Reason: the unsigned types are being properly taken into account + # and converted to [0, 1] using their full scale. + # Further note: float64 should be used for the numpy array in case + # image is stored as 'double', we're keeping it float32 to stay + # consistent with the CellProfiler reader (the double type is also + # converted to single precision) + numpy_image = \ + (numpy_image.astype(numpy.float32) + float(min_value)) / \ + (float(max_value) - float(min_value)) + if wants_max_intensity: + return numpy_image, max_value + return numpy_image + + def read_volume(self, + series=None, + c=None, + z=None, + t=None, + rescale=True, + xywh=None, + wants_max_intensity=False, + channel_names=None, + ): + # Forward 3D calls to the standard reader function + return self.read( + series=series, + c=c, + z=z, + t=t, + rescale=rescale, + xywh=xywh, + wants_max_intensity=wants_max_intensity, + channel_names=channel_names, + volumetric=True + ) + + def read_planes(self, z=0, c=None, t=0, tile=None): + """ + Creates RawPixelsStore and reads planes from the OMERO server. + """ + channels = [] + if c is None: + channel_count = self.pixels.getSizeC().val + if channel_count == 1: + # This is obviously greyscale, treat it as such. + channels.append(0) + c = 0 + else: + channels = range(channel_count) + else: + channels.append(c) + pixel_type = self.pixels.getPixelsType().value.val + numpy_type = PIXEL_TYPES[pixel_type][1] + raw_pixels_store = self.login.session.createRawPixelsStore() + try: + raw_pixels_store.setPixelsId( + self.pixels.getId().val, True, self.context) + LOGGER.debug("Reading pixels Id: %s" % self.pixels.getId().val) + LOGGER.debug("Reading channels %s" % channels) + planes = [] + for channel in channels: + if tile is None: + sizeX = self.width + sizeY = self.height + raw_plane = raw_pixels_store.getPlane( + z, channel, t, self.context) + else: + x, y, sizeX, sizeY = tile + raw_plane = raw_pixels_store.getTile( + z, channel, t, x, y, sizeX, sizeY) + convert_type = '>%d%s' % ( + (sizeY * sizeX), PIXEL_TYPES[pixel_type][0]) + converted_plane = unpack(convert_type, raw_plane) + plane = numpy.array(converted_plane, numpy_type) + plane.resize(sizeY, sizeX) + planes.append(plane) + if c is None: + return numpy.dstack(planes) + else: + return planes[0] + except Exception: + LOGGER.error("Failed to get plane from OMERO", exc_info=True) + finally: + raw_pixels_store.close() + + def read_planes_volumetric(self, z=None, c=None, t=None, tile=None): + """ + Creates RawPixelsStore and reads planes from the OMERO server. + """ + if t is not None and z is not None: + raise ValueError(f"Specified parameters {z=}, {t=} would not produce a 3D image") + if z is None: + size_z = self.pixels.getSizeZ().val + else: + size_z = 1 + if t is None: + size_t = self.pixels.getSizeT().val + else: + size_t = 1 + pixel_type = self.pixels.getPixelsType().value.val + numpy_type = PIXEL_TYPES[pixel_type][1] + raw_pixels_store = self.login.session.createRawPixelsStore() + if size_z > 1: + # We assume z is the desired 3D dimension if present and not specified. + t_range = [t or 0] + z_range = range(size_z) + elif size_t > 1: + t_range = range(size_t) + z_range = [z or 0] + else: + # Weird, but perhaps user's 3D image only had 1 plane in this acquisition. + t_range = [t or 0] + z_range = [z or 0] + planes = [] + try: + raw_pixels_store.setPixelsId( + self.pixels.getId().val, True, self.context) + LOGGER.debug("Reading pixels Id: %s" % self.pixels.getId().val) + + for z_index in z_range: + for t_index in t_range: + if tile is None: + size_x = self.width + size_y = self.height + raw_plane = raw_pixels_store.getPlane( + z_index, c, t_index, self.context) + else: + x, y, size_x, size_y = tile + raw_plane = raw_pixels_store.getTile( + z_index, c, t_index, x, y, size_x, size_y) + convert_type = '>%d%s' % ( + (size_y * size_x), PIXEL_TYPES[pixel_type][0]) + converted_plane = unpack(convert_type, raw_plane) + plane = numpy.array(converted_plane, numpy_type) + plane.resize(size_y, size_x) + planes.append(plane) + return numpy.dstack(planes) + except Exception: + LOGGER.error("Failed to get plane from OMERO", exc_info=True) + finally: + raw_pixels_store.close() + + @classmethod + def supports_url(cls): + # We read OMERO URLs directly without caching a download. + return True + + @classmethod + def supports_format(cls, image_file, allow_open=False, volume=False): + if image_file.scheme not in cls.supported_schemes: + # I can't read this + return -1 + if image_file.scheme == "omero": + # Yes please + return 1 + elif "?show=image" in image_file.url.lower(): + # Looks enough like an OMERO URL that we'll have a go. + return 2 + return -1 + + def close(self): + # We don't activate any file locks. + pass + + def get_series_metadata(self): + """ + OMERO image IDs only ever refer to a single series + """ + self.init_reader() + LOGGER.info(f"Extracting metadata for image {self.image_id}") + meta_dict = collections.defaultdict(list) + meta_dict[MD_SIZE_S] = 1 + meta_dict[MD_SIZE_X].append(self.width) + meta_dict[MD_SIZE_Y].append(self.height) + meta_dict[MD_SIZE_C].append(self.pixels.getSizeC().val) + meta_dict[MD_SIZE_Z].append(self.pixels.getSizeZ().val) + meta_dict[MD_SIZE_T].append(self.pixels.getSizeT().val) + meta_dict[MD_SERIES_NAME].append(self.omero_image.getName().val) + return meta_dict + + @staticmethod + def get_settings(): + # Define settings available in the reader + return [ + ('allow_token', + "Allow OMERO user tokens", + """ + If enabled, this reader will attempt to use OMERO user tokens to + establish a server connection. + """, + bool, + True), + ('show_server', + "Display 'server connected' popup", + """ + If enabled, a popup will be shown when a server is automatically connected to using a user token. + """, + bool, + True) + ] diff --git a/active_plugins/saveimagestoomero.py b/active_plugins/saveimagestoomero.py new file mode 100644 index 0000000..da60946 --- /dev/null +++ b/active_plugins/saveimagestoomero.py @@ -0,0 +1,590 @@ +""" +SaveImagesToOMERO +================== + +**SaveImagesToOMERO** saves image or movies directly onto an +OMERO server. + +# Installation - +Easy mode - clone the plugins repository and point your CellProfiler plugins folder to this folder. +Navigate to /active_plugins/ and run `pip install -e .[omero]` to install dependencies. + +## Manual Installation + +Add this file plus the `omero_helper` directory into your CellProfiler plugins folder. Install dependencies into +your CellProfiler Python environment. + +## Installing dependencies - +This depends on platform. At the most basic level you'll need the `omero-py` package and the `omero_user_token` package. + +Both should be possible to pip install on Windows. On MacOS, you'll probably have trouble with the zeroc-ice dependency. +omero-py uses an older version and so needs specific wheels. Fortunately we've built some for you. +Macos - https://github.com/glencoesoftware/zeroc-ice-py-macos-x86_64/releases/latest +Linux (Generic) - https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/latest +Ubuntu 22.04 - https://github.com/glencoesoftware/zeroc-ice-py-ubuntu2204-x86_64/releases/latest + +Download the .whl file from whichever is most appropriate and run `pip install `. + +From there pip install omero-py should do the rest. + +You'll also want the `omero_user_token` package to help manage logins (`pip install omero_user_token`). +This allows you to set reusable login tokens for quick reconnection to a server. These tokens are required for using +headless mode/analysis mode. + +| + +============ ============ =============== +Supports 2D? Supports 3D? Respects masks? +============ ============ =============== +YES YES YES +============ ============ =============== + +""" + +import logging +import os + +import numpy +import skimage +from cellprofiler_core.module import Module +from cellprofiler_core.preferences import get_headless +from cellprofiler_core.setting import Binary +from cellprofiler_core.setting import ValidationError +from cellprofiler_core.setting.choice import Choice +from cellprofiler_core.setting.do_something import DoSomething +from cellprofiler_core.setting.subscriber import ImageSubscriber, FileImageSubscriber +from cellprofiler_core.setting.text import Integer, Text +from cellprofiler_core.constants.setting import get_name_providers + +from cellprofiler.modules import _help + +from omero_helper.connect import CREDENTIALS, login + +LOGGER = logging.getLogger(__name__) + +FN_FROM_IMAGE = "From image filename" +FN_SEQUENTIAL = "Sequential numbers" +FN_SINGLE_NAME = "Single name" + +SINGLE_NAME_TEXT = "Enter single file name" +SEQUENTIAL_NUMBER_TEXT = "Enter file prefix" + +BIT_DEPTH_8 = "8-bit integer" +BIT_DEPTH_16 = "16-bit integer" +BIT_DEPTH_FLOAT = "32-bit floating point" +BIT_DEPTH_RAW = "No conversion" + +WS_EVERY_CYCLE = "Every cycle" +WS_FIRST_CYCLE = "First cycle" +WS_LAST_CYCLE = "Last cycle" + + +class SaveImagesToOMERO(Module): + module_name = "SaveImagesToOMERO" + variable_revision_number = 1 + category = ["File Processing"] + + def create_settings(self): + self.target_object_id = Integer( + text="OMERO ID of the parent dataset", + minval=1, + doc="""\ + The created images must be added to an OMERO dataset. + Enter the OMERO ID of the Dataset object you'd like to associate + the the image with. This ID can be found by locating the target object + in OMERO.web (ID and type is displayed in the right panel). + + To use a new dataset, first create this in OMERO.web and then enter the ID here""", + ) + + self.test_connection_button = DoSomething( + "Test the OMERO connection", + "Test connection", + self.test_connection, + doc="""\ +This button test the connection to the OMERO server specified using +the settings entered by the user.""", + ) + + self.image_name = ImageSubscriber( + "Select the image to save", doc="Select the image you want to save." + ) + + self.bit_depth = Choice( + "Image bit depth conversion", + [BIT_DEPTH_8, BIT_DEPTH_16, BIT_DEPTH_FLOAT, BIT_DEPTH_RAW], + BIT_DEPTH_RAW, + doc=f"""\ + Select the bit-depth at which you want to save the images. CellProfiler + typically works with images scaled into the 0-1 range. This setting lets + you transform that into other scales. + + Selecting *{BIT_DEPTH_RAW}* will attempt to upload data without applying + any transformations. This could be used to save integer labels + in 32-bit float format if you had more labels than the 16-bit format can + handle (without rescaling to the 0-1 range of *{BIT_DEPTH_FLOAT}*). + N.B. data compatibility with OMERO is not checked. + + *{BIT_DEPTH_8}* and *{BIT_DEPTH_16}* will attempt to rescale values to + be in the range 0-255 and 0-65535 respectively. These are typically + used with external tools. + + *{BIT_DEPTH_FLOAT}* saves the image as floating-point decimals with + 32-bit precision. When the input data is integer or binary type, pixel + values are scaled within the range (0, 1). Floating point data is not + rescaled.""", + ) + + self.file_name_method = Choice( + "Select method for constructing file names", + [FN_FROM_IMAGE, FN_SEQUENTIAL, FN_SINGLE_NAME], + FN_FROM_IMAGE, + doc="""\ + *(Used only if saving non-movie files)* + + Several choices are available for constructing the image file name: + + - *{FN_FROM_IMAGE}:* The filename will be constructed based on the + original filename of an input image specified in **NamesAndTypes**. + You will have the opportunity to prefix or append additional text. + + If you have metadata associated with your images, you can append + text to the image filename using a metadata tag. This is especially + useful if you want your output given a unique label according to the + metadata corresponding to an image group. The name of the metadata to + substitute can be provided for each image for each cycle using the + **Metadata** module. + - *{FN_SEQUENTIAL}:* Same as above, but in addition, each filename + will have a number appended to the end that corresponds to the image + cycle number (starting at 1). + - *{FN_SINGLE_NAME}:* A single name will be given to the file. Since + the filename is fixed, this file will be overwritten with each cycle. + In this case, you would probably want to save the image on the last + cycle (see the *Select how often to save* setting). The exception to + this is to use a metadata tag to provide a unique label, as mentioned + in the *{FN_FROM_IMAGE}* option. + + {USING_METADATA_TAGS_REF} + + {USING_METADATA_HELP_REF} + """.format( + **{ + "FN_FROM_IMAGE": FN_FROM_IMAGE, + "FN_SEQUENTIAL": FN_SEQUENTIAL, + "FN_SINGLE_NAME": FN_SINGLE_NAME, + "USING_METADATA_HELP_REF": _help.USING_METADATA_HELP_REF, + "USING_METADATA_TAGS_REF": _help.USING_METADATA_TAGS_REF, + } + ), + ) + + self.file_image_name = FileImageSubscriber( + "Select image name for file prefix", + "None", + doc="""\ + *(Used only when “{FN_FROM_IMAGE}” is selected for constructing the filename)* + + Select an image loaded using **NamesAndTypes**. The original filename + will be used as the prefix for the output filename.""".format( + **{"FN_FROM_IMAGE": FN_FROM_IMAGE} + ), + ) + + self.single_file_name = Text( + SINGLE_NAME_TEXT, + "OrigBlue", + metadata=True, + doc="""\ + *(Used only when “{FN_SEQUENTIAL}” or “{FN_SINGLE_NAME}” are selected + for constructing the filename)* + + Specify the filename text here. If you have metadata associated with + your images, enter the filename text with the metadata tags. + {USING_METADATA_TAGS_REF} + Do not enter the file extension in this setting; it will be appended + automatically.""".format( + **{ + "FN_SEQUENTIAL": FN_SEQUENTIAL, + "FN_SINGLE_NAME": FN_SINGLE_NAME, + "USING_METADATA_TAGS_REF": _help.USING_METADATA_TAGS_REF, + } + ), + ) + + self.number_of_digits = Integer( + "Number of digits", + 4, + doc="""\ + *(Used only when “{FN_SEQUENTIAL}” is selected for constructing the filename)* + + Specify the number of digits to be used for the sequential numbering. + Zeros will be used to left-pad the digits. If the number specified here + is less than that needed to contain the number of image sets, the latter + will override the value entered.""".format( + **{"FN_SEQUENTIAL": FN_SEQUENTIAL} + ), + ) + + self.wants_file_name_suffix = Binary( + "Append a suffix to the image file name?", + False, + doc="""\ + Select "*{YES}*" to add a suffix to the image’s file name. Select "*{NO}*" + to use the image name as-is. + """.format( + **{"NO": "No", "YES": "Yes"} + ), + ) + + self.file_name_suffix = Text( + "Text to append to the image name", + "", + metadata=True, + doc="""\ + *(Used only when constructing the filename from the image filename)* + + Enter the text that should be appended to the filename specified above. + If you have metadata associated with your images, you may use metadata tags. + + {USING_METADATA_TAGS_REF} + + Do not enter the file extension in this setting; it will be appended + automatically. + """.format( + **{"USING_METADATA_TAGS_REF": _help.USING_METADATA_TAGS_REF} + ), + ) + + self.when_to_save = Choice( + "When to save", + [WS_EVERY_CYCLE, WS_FIRST_CYCLE, WS_LAST_CYCLE], + WS_EVERY_CYCLE, + doc="""\ + Specify at what point during pipeline execution to save file(s). + + - *{WS_EVERY_CYCLE}:* Useful for when the image of interest is + created every cycle and is not dependent on results from a prior + cycle. + - *{WS_FIRST_CYCLE}:* Useful for when you are saving an aggregate + image created on the first cycle, e.g., + **CorrectIlluminationCalculate** with the *All* setting used on + images obtained directly from **NamesAndTypes**. + - *{WS_LAST_CYCLE}:* Useful for when you are saving an aggregate image + completed on the last cycle, e.g., **CorrectIlluminationCalculate** + with the *All* setting used on intermediate images generated during + each cycle.""".format( + **{ + "WS_EVERY_CYCLE": WS_EVERY_CYCLE, + "WS_FIRST_CYCLE": WS_FIRST_CYCLE, + "WS_LAST_CYCLE": WS_LAST_CYCLE, + } + ), + ) + + def settings(self): + result = [ + self.target_object_id, + self.image_name, + self.bit_depth, + self.file_name_method, + self.file_image_name, + self.single_file_name, + self.number_of_digits, + self.wants_file_name_suffix, + self.file_name_suffix, + self.when_to_save, + ] + return result + + def visible_settings(self): + result = [self.target_object_id, self.test_connection_button, + self.image_name, self.bit_depth, self.file_name_method] + + if self.file_name_method == FN_FROM_IMAGE: + result += [self.file_image_name, self.wants_file_name_suffix] + if self.wants_file_name_suffix: + result.append(self.file_name_suffix) + elif self.file_name_method == FN_SEQUENTIAL: + self.single_file_name.text = SEQUENTIAL_NUMBER_TEXT + result.append(self.single_file_name) + result.append(self.number_of_digits) + elif self.file_name_method == FN_SINGLE_NAME: + self.single_file_name.text = SINGLE_NAME_TEXT + result.append(self.single_file_name) + else: + raise NotImplementedError( + "Unhandled file name method: %s" % self.file_name_method + ) + result.append(self.when_to_save) + return result + + def help_settings(self): + return [ + self.target_object_id, + self.image_name, + self.bit_depth, + self.file_name_method, + self.file_image_name, + self.single_file_name, + self.number_of_digits, + self.wants_file_name_suffix, + self.file_name_suffix, + self.when_to_save, + ] + + def validate_module(self, pipeline): + # Make sure metadata tags exist + if self.file_name_method == FN_SINGLE_NAME or ( + self.file_name_method == FN_FROM_IMAGE and self.wants_file_name_suffix.value + ): + text_str = ( + self.single_file_name.value + if self.file_name_method == FN_SINGLE_NAME + else self.file_name_suffix.value + ) + undefined_tags = pipeline.get_undefined_metadata_tags(text_str) + if len(undefined_tags) > 0: + raise ValidationError( + "%s is not a defined metadata tag. Check the metadata specifications in your load modules" + % undefined_tags[0], + self.single_file_name + if self.file_name_method == FN_SINGLE_NAME + else self.file_name_suffix, + ) + if self.when_to_save in (WS_FIRST_CYCLE, WS_EVERY_CYCLE): + # + # Make sure that the image name is available on every cycle + # + for setting in get_name_providers(pipeline, self.image_name): + if setting.provided_attributes.get("available_on_last"): + # + # If we fell through, then you can only save on the last cycle + # + raise ValidationError( + "%s is only available after processing all images in an image group" + % self.image_name.value, + self.when_to_save, + ) + + def test_connection(self): + """Check to make sure the OMERO server is remotely accessible""" + # CREDENTIALS is a singleton so we can safely grab it here. + if CREDENTIALS.client is None: + login() + if CREDENTIALS.client is None: + msg = "OMERO connection failed" + else: + msg = f"Connected to {CREDENTIALS.server}" + else: + msg = f"Already connected to {CREDENTIALS.server}" + if CREDENTIALS.client is not None: + try: + self.get_omero_parent() + msg += f"\n\nFound parent object {self.target_object_id}" + except ValueError as ve: + msg += f"\n\n{ve}" + + import wx + wx.MessageBox(msg) + + def make_full_filename(self, file_name, workspace=None, image_set_index=None): + """Convert a file name into an absolute path + + We do a few things here: + * apply metadata from an image set to the file name if an + image set is specified + * change the relative path into an absolute one using the "." and "&" + convention + * Create any directories along the path + """ + if image_set_index is not None and workspace is not None: + file_name = workspace.measurements.apply_metadata( + file_name, image_set_index + ) + measurements = None if workspace is None else workspace.measurements + path_name = self.directory.get_absolute_path(measurements, image_set_index) + file_name = os.path.join(path_name, file_name) + path, file = os.path.split(file_name) + if not os.path.isdir(path): + os.makedirs(path) + return os.path.join(path, file) + + @staticmethod + def connect_to_omero(): + if CREDENTIALS.client is None: + if get_headless(): + connected = login() + if not connected: + raise ValueError("No OMERO connection established") + else: + login() + if CREDENTIALS.client is None: + raise ValueError("OMERO connection failed") + + def prepare_run(self, workspace): + """Prepare to run the pipeline. + Establish a connection to OMERO.""" + pipeline = workspace.pipeline + + if pipeline.in_batch_mode(): + return True + + # Verify that we're able to connect to a server + self.connect_to_omero() + + return True + + def get_omero_conn(self): + self.connect_to_omero() + return CREDENTIALS.get_gateway() + + def get_omero_parent(self): + conn = self.get_omero_conn() + parent_id = self.target_object_id.value + parent_type = "Dataset" + old_group = conn.SERVICE_OPTS.getOmeroGroup() + # Search across groups + conn.SERVICE_OPTS.setOmeroGroup(-1) + parent_ob = conn.getObject(parent_type, parent_id) + if parent_ob is None: + raise ValueError(f"{parent_type} ID {parent_id} not found on server") + conn.SERVICE_OPTS.setOmeroGroup(old_group) + return parent_ob + + def run(self, workspace): + if self.show_window: + workspace.display_data.wrote_image = False + + if self.when_to_save == WS_FIRST_CYCLE and workspace.measurements["Image", "Group_Index", ] > 1: + # We're past the first image set + return + elif self.when_to_save == WS_LAST_CYCLE: + # We do this in post group + return + + self.save_image(workspace) + + def save_image(self, workspace): + # Re-establish server connection + self.connect_to_omero() + + filename = self.get_filename(workspace) + + image = workspace.image_set.get_image(self.image_name.value) + + omero_image = self.upload_image_to_omero(image, filename) + + if self.show_window: + workspace.display_data.wrote_image = True + im_id = omero_image.getId() + path = f"https://{CREDENTIALS.server}/webclient/?show=image-{im_id}" + workspace.display_data.header = ["Image Name", "OMERO ID", "Server Location"] + workspace.display_data.columns = [[filename, im_id, path]] + + def post_group(self, workspace, *args): + if self.when_to_save == WS_LAST_CYCLE: + self.save_image(workspace) + + def upload_image_to_omero(self, image, name): + pixels = image.pixel_data.copy() + volumetric = image.volumetric + multichannel = image.multichannel + + if self.bit_depth.value == BIT_DEPTH_8: + pixels = skimage.util.img_as_ubyte(pixels) + elif self.bit_depth.value == BIT_DEPTH_16: + pixels = skimage.util.img_as_uint(pixels) + elif self.bit_depth.value == BIT_DEPTH_FLOAT: + pixels = skimage.util.img_as_float32(pixels) + elif self.bit_depth.value == BIT_DEPTH_RAW: + # No bit depth transformation + pass + else: + raise NotImplementedError(f"Unknown bit depth {self.bit_depth.value}") + + conn = self.get_omero_conn() + parent = self.get_omero_parent() + parent_group = parent.details.group.id.val + old_group = conn.SERVICE_OPTS.getOmeroGroup() + conn.SERVICE_OPTS.setOmeroGroup(parent_group) + shape = pixels.shape + if multichannel: + size_c = shape[-1] + else: + size_c = 1 + if volumetric: + size_z = shape[2] + else: + size_z = 1 + + new_shape = list(shape) + while len(new_shape) < 4: + new_shape.append(1) + upload_pixels = numpy.reshape(pixels, new_shape) + + def slice_iterator(): + for z in range(size_z): + for c in range(size_c): + yield upload_pixels[:, :, z, c] + + # Upload the image data to OMERO + LOGGER.debug("Transmitting data for image") + omero_image = conn.createImageFromNumpySeq( + slice_iterator(), name, size_z, size_c, 1, description="Image uploaded from CellProfiler", + dataset=parent) + LOGGER.debug("Transmission successful") + conn.SERVICE_OPTS.setOmeroGroup(old_group) + return omero_image + + def get_filename(self, workspace): + """Concoct a filename for the current image based on the user settings""" + measurements = workspace.measurements + if self.file_name_method == FN_SINGLE_NAME: + filename = self.single_file_name.value + filename = workspace.measurements.apply_metadata(filename) + elif self.file_name_method == FN_SEQUENTIAL: + filename = self.single_file_name.value + filename = workspace.measurements.apply_metadata(filename) + n_image_sets = workspace.measurements.image_set_count + ndigits = int(numpy.ceil(numpy.log10(n_image_sets + 1))) + ndigits = max((ndigits, self.number_of_digits.value)) + padded_num_string = str(measurements.image_set_number).zfill(ndigits) + filename = "%s%s" % (filename, padded_num_string) + else: + file_name_feature = self.source_file_name_feature + filename = measurements.get_current_measurement("Image", file_name_feature) + filename = os.path.splitext(filename)[0] + if self.wants_file_name_suffix: + suffix = self.file_name_suffix.value + suffix = workspace.measurements.apply_metadata(suffix) + filename += suffix + return filename + + @property + def source_file_name_feature(self): + """The file name measurement for the exemplar disk image""" + return "_".join(("FileName", self.file_image_name.value)) + + def display(self, workspace, figure): + if not workspace.display_data.columns: + # Nothing to display + return + figure.set_subplots((1, 1)) + figure.subplot_table( + 0, + 0, + workspace.display_data.columns, + col_labels=workspace.display_data.header, + ) + + def display_post_run(self, workspace, figure): + self.display(workspace, figure) + + def is_aggregation_module(self): + """SaveImagesToOMERO is an aggregation module when it writes on the last cycle""" + return ( + self.when_to_save == WS_LAST_CYCLE + ) + + def volumetric(self): + return True diff --git a/documentation/CP-plugins-documentation/OMERO.md b/documentation/CP-plugins-documentation/OMERO.md new file mode 100644 index 0000000..6b80d6f --- /dev/null +++ b/documentation/CP-plugins-documentation/OMERO.md @@ -0,0 +1,100 @@ +# OMERO Plugins + +[OMERO](https://www.openmicroscopy.org/omero/) is an image data management server developed by the Open Microscopy Environment. +It allows for the storage and retrieval of image datasets and associated metadata. + + +## Using OMERO with CellProfiler + +The OMERO plugins can be used to connect to an OMERO server and exchange data with CellProfiler. You'll need to supply the server address and login credentials to establish a connection. + +The current iteration of the plugins supports a single active connection at any given time. This means that pipelines should only be requesting data from a single OMERO server. + +Supplying credentials every run can be repetitive, so the plugins will also detect and make use of tokens set by the [omero-user-token](https://github.com/glencoesoftware/omero-user-token) package. This provides a long-lasting mechanism for +reconnecting to a previously established OMERO session. N.b. tokens which are set to never expire will only be valid until the OMERO server restarts. + +## The connection interface + +When installed correctly, OMERO-related entries will be available in the CellProfiler '**Plugins**' menu (this menu only appears when an installed plugin uses it). You'll find _Connect to OMERO_ menu entries allowing you +to connect to an OMERO server. If a user token already exists this will be used to establish a connection, otherwise you'll see a dialog where credentials can be supplied. You can also use the +_Connect to OMERO (no token)_ option to skip checking for a login token, this is mostly useful if you need to switch server. + +After entering credentials and establishing a connection, a _Set Token_ button is available to create an omero-user-token on your system. This will allow +you to reconnect to the same server automatically without entering credentials. Tokens are set per-user, and only a single token can be stored at a time. This means that you +**should not** set tokens if using a shared user account. + +An active OMERO connection is required for most functionality in these plugins. A single connection can be made at a time, and the +plugin will automatically sustain, manage and safely shut down this connection as needed. The OMERO plugin will disconnect automatically when +quitting CellProfiler. The connection dialog should automatically display if you try to run the plugin without a connection. + +When reconnecting via a token, the plugin will display a message clarifying which server was connected to. This message can be disabled by ticking the _do not show again_ box. This +can be re-enabled using the reader configuration interface in the _File->Configure Readers_ menu. Under the OMEROReader reader config +you'll also find an option to entirely disable token usage, in case you need to use different credentials elsewhere on your machine. + +## Loading image data + +The plugin suite includes the OMEROReader image reader, which can be used by both NamesAndTypes and LoadData. + +To load images with the OMERO plugin we need to supply an image's unique OMERO ID. In OMERO.web, this is visible in the right pane with an image selected. We can supply image IDs to the file list in two forms: + +- As a URL in the format `https://my.omero.server/webclient/?show=image-3654` +- Using the (legacy) format `omero:iid=3654` + +Supplying the full URL is recommended, since this provides CellProfiler with the actual server address too. + +In previous versions of the integration, you needed to create a text file with one image per line and then use _File->Import->File List_ to load them +into CellProfiler. As of CellProfiler 5 you should be able to simply copy/paste these image links into the file list in the Images module. + +There is also a _Browse OMERO for Images_ option in the Plugins menu. This provides a fully featured interface for browsing an OMERO server and adding images to your pipeline. +Images can be added by selecting them and using the _Add to file list_ button, or by dragging from the tree pane onto the main file list. + +As for CellProfiler 5, these plugins also now interpret image channels correctly. If the image on OMERO contains multiple channels, you should either set the +image types in NamesAndTypes to _Color_ mode instead of _Greyscale_. Alternatively you can use the _Extract image planes_ option in the Images module and enable +splitting by channel to generate a single greyscale entry for each channel in the image. + +It should go without saying that the OMERO account you login to the server with needs to have permissions to view/read the image. + +It may be advisable to use an [OMERO.script](https://omero.readthedocs.io/en/stable/developers/scripts/index.html) to generate any large file lists that +you want to load from OMERO. + +Some OMERO servers may have the [omero-ms-pixel-buffer](https://github.com/glencoesoftware/omero-ms-pixel-buffer) microservice installed. This provides a conventional +HTTP API for fetching image data using a specially formatted URL. Since these are seen as standard file downloads the OMERO plugin is not needed to load data from this microservice into CellProfiler. + +## Saving data to OMERO + +The OMERO integration includes two module plugins for sending data back to OMERO: SaveImagesToOMERO and ExportToOMEROTable. + +### SaveImagesToOMERO + +The SaveImagesToOMERO plugin functions similarly to SaveImages, but the exported image is instead uploaded to the OMERO server. + +Images on OMERO are generally contained within Datasets. Datasets also have a unique ID visible within the right panel of OMERO.web. +To use the module you'll need to supply the target dataset's ID (or other parent object type), and resulting images will be uploaded to that dataset. + +On OMERO image names do not need to be unique (only image IDs). You may want to use metadata fields to construct a distinguishable name for each uploaded image. + +At present uploaded images are not linked to any data previously read from OMERO, so make sure you have a means of identifying the image from it's name. + +### ExportToOMEROTable + +[OMERO tables](https://omero.readthedocs.io/en/stable/developers/Tables.html) are tabular data stores which can be viewed in OMERO.web. Like all OMERO objects, tables are +associated with a parent object (typically an image or dataset). You'll need to provide an ID for the parent object. + +The module functions similarly to ExportToDatabase, in that measurements are uploaded after each image set completes. One caveat is that +it is not possible to add columns to an OMERO.table after it's initial creation, therefore certain meta-measurements are not available in this module. + +To retrieve and analyse data from OMERO.tables in other software, you should be able to use the [omero2pandas](https://github.com/glencoesoftware/omero2pandas) package. This will retrieve +table data as Pandas dataframes, allowing for their use with the wider Python scientific stack. + +## Troubleshooting + +- OMERO's Python API currently depends on a very specific version of the `zeroc-ice` package, which can be difficult to build and install. +The setup.py dependency manager has been supplied with several prebuilt wheels which should cater to most systems. Please raise an issue if you encounter problems. + +- When a server connection is established, CellProfiler will ping the server periodically to keep the session going. +The server connections may time out if your machine enters sleep mode for a prolonged period. If you see errors after waking from sleep, try re-running the _Connect to OMERO_ dialog. + +- To upload data you'll need relevant permissions both for the OMERO group and for whichever object you're attaching data to. The plugin's test button will +verify that the object exists, but not that you have write permissions. + +- Uploaded images from SaveImagesToOMERO are treated as fresh data by OMERO. Any channel settings and other metadata will not be copied over from loaded image. diff --git a/documentation/CP-plugins-documentation/supported_plugins.md b/documentation/CP-plugins-documentation/supported_plugins.md index f013663..3dce741 100644 --- a/documentation/CP-plugins-documentation/supported_plugins.md +++ b/documentation/CP-plugins-documentation/supported_plugins.md @@ -10,19 +10,22 @@ Most plugin documentation can be found within the plugin itself and can be acces Those plugins that do have extra documentation contain links below. -| Plugin | Description | Requires installation of dependencies? | Install flag | Docker version currently available? | -|--------|-------------|----------------------------------------|--------------|-------------------------------------| -| CalculateMoments | CalculateMoments extracts moments statistics from a given distribution of pixel values. | No | | N/A | -| CallBarcodes | CallBarcodes is used for assigning a barcode to an object based on the channel with the strongest intensity for a given number of cycles. It is used for optical sequencing by synthesis (SBS). | No | | N/A | -| CompensateColors | CompensateColors determines how much signal in any given channel is because of bleed-through from another channel and removes the bleed-through. It can be performed across an image or masked to objects and provides a number of preprocessing and rescaling options to allow for troubleshooting if input image intensities are not well matched. | No | | N/A | -| DistanceTransform | DistanceTransform computes the distance transform of a binary image. The distance of each foreground pixel is computed to the nearest background pixel and the resulting image is then scaled so that the largest distance is 1. | No | | N/A | -| EnhancedMeasureTexture| EnhancedMeasureTexture measures the degree and nature of textures within an image or objects in a more comprehensive/tuneable manner than the MeasureTexture module native to CellProfiler. | No | | N/A | -| HistogramEqualization | HistogramEqualization increases the global contrast of a low-contrast image or volume. Histogram equalization redistributes intensities to utilize the full range of intensities, such that the most common frequencies are more distinct. This module can perform either global or local histogram equalization. | No | | N/A | -| HistogramMatching | HistogramMatching manipulates the pixel intensity values an input image and matches them to the histogram of a reference image. It can be used as a way to normalize intensities across different 2D or 3D images or different frames of the same 3D image. It allows you to choose which frame to use as the reference. | No | | N/A | -| PixelShuffle | PixelShuffle takes the intensity of each pixel in an image and randomly shuffles its position. | No | | N/A | -| Predict | Predict allows you to use an ilastik pixel classifier to generate a probability image. CellProfiler supports two types of ilastik projects: Pixel Classification and Autocontext (2-stage). | No | | N/A | -| [RunCellpose](RunCellPose.md) | RunCellpose allows you to run Cellpose within CellProfiler. Cellpose is a generalist machine-learning algorithm for cellular segmentation and is a great starting point for segmenting non-round cells. You can use pre-trained Cellpose models or your custom model with this plugin. You can use a GPU with this module to dramatically increase your speed/efficiency. | Yes | `cellpose` | Yes | -| RunImageJScript | RunImageJScript allows you to run any supported ImageJ script directly within CellProfiler. It is significantly more performant than RunImageJMacro, and is also less likely to leave behind temporary files. | Yes | `imagejscript` , though note that conda installation may be preferred, see [this link](https://py.imagej.net/en/latest/Install.html#installing-via-pip) for more information | No | -| RunOmnipose | RunOmnipose allows you to run Omnipose within CellProfiler. Omnipose is a general image segmentation tool that builds on Cellpose. | Yes | `omnipose` | No | -| RunStarDist | RunStarDist allows you to run StarDist within CellProfiler. StarDist is a machine-learning algorithm for object detection with star-convex shapes making it best suited for nuclei or round-ish cells. You can use pre-trained StarDist models or your custom model with this plugin. You can use a GPU with this module to dramatically increase your speed/efficiency. RunStarDist is generally faster than RunCellpose. | Yes | `stardist` | No | -| VarianceTransform | This module allows you to calculate the variance of an image, using a determined window size. It also has the option to find the optimal window size from a predetermined range to obtain the maximum variance of an image. | No | | N/A | +| Plugin | Description | Requires installation of dependencies? | Install flag | Docker version currently available? | +|--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------|--------|-------------------------------------| +| CalculateMoments | CalculateMoments extracts moments statistics from a given distribution of pixel values. | No | | N/A | +| CallBarcodes | CallBarcodes is used for assigning a barcode to an object based on the channel with the strongest intensity for a given number of cycles. It is used for optical sequencing by synthesis (SBS). | No | | N/A | +| CompensateColors | CompensateColors determines how much signal in any given channel is because of bleed-through from another channel and removes the bleed-through. It can be performed across an image or masked to objects and provides a number of preprocessing and rescaling options to allow for troubleshooting if input image intensities are not well matched. | No | | N/A | +| DistanceTransform | DistanceTransform computes the distance transform of a binary image. The distance of each foreground pixel is computed to the nearest background pixel and the resulting image is then scaled so that the largest distance is 1. | No | | N/A | +| EnhancedMeasureTexture | EnhancedMeasureTexture measures the degree and nature of textures within an image or objects in a more comprehensive/tuneable manner than the MeasureTexture module native to CellProfiler. | No | | N/A | +| HistogramEqualization | HistogramEqualization increases the global contrast of a low-contrast image or volume. Histogram equalization redistributes intensities to utilize the full range of intensities, such that the most common frequencies are more distinct. This module can perform either global or local histogram equalization. | No | | N/A | +| HistogramMatching | HistogramMatching manipulates the pixel intensity values an input image and matches them to the histogram of a reference image. It can be used as a way to normalize intensities across different 2D or 3D images or different frames of the same 3D image. It allows you to choose which frame to use as the reference. | No | | N/A | +| PixelShuffle | PixelShuffle takes the intensity of each pixel in an image and randomly shuffles its position. | No | | N/A | +| Predict | Predict allows you to use an ilastik pixel classifier to generate a probability image. CellProfiler supports two types of ilastik projects: Pixel Classification and Autocontext (2-stage). | No | | N/A | +| [RunCellpose](RunCellPose.md) | RunCellpose allows you to run Cellpose within CellProfiler. Cellpose is a generalist machine-learning algorithm for cellular segmentation and is a great starting point for segmenting non-round cells. You can use pre-trained Cellpose models or your custom model with this plugin. You can use a GPU with this module to dramatically increase your speed/efficiency. | Yes | `cellpose` | Yes | +| RunImageJScript | RunImageJScript allows you to run any supported ImageJ script directly within CellProfiler. It is significantly more performant than RunImageJMacro, and is also less likely to leave behind temporary files. | Yes | `imagejscript` , though note that conda installation may be preferred, see [this link](https://py.imagej.net/en/latest/Install.html#installing-via-pip) for more information | No | +| RunOmnipose | RunOmnipose allows you to run Omnipose within CellProfiler. Omnipose is a general image segmentation tool that builds on Cellpose. | Yes | `omnipose` | No | +| RunStarDist | RunStarDist allows you to run StarDist within CellProfiler. StarDist is a machine-learning algorithm for object detection with star-convex shapes making it best suited for nuclei or round-ish cells. You can use pre-trained StarDist models or your custom model with this plugin. You can use a GPU with this module to dramatically increase your speed/efficiency. RunStarDist is generally faster than RunCellpose. | Yes | `stardist` | No | +| VarianceTransform | This module allows you to calculate the variance of an image, using a determined window size. It also has the option to find the optimal window size from a predetermined range to obtain the maximum variance of an image. | No | | N/A | +| [OMEROReader](OMERO.md) | This reader allows you to connect to and load images from OMERO servers. | Yes | `omero` | No | +| [SaveImagesToOMERO](OMERO.md) | A module to upload resulting images directly onto OMERO servers. | Yes | `omero` | No | +| [ExportToOMEROTable](OMERO.md) | A module to upload results tables directly onto OMERO servers. | Yes | `omero` | No | diff --git a/setup.py b/setup.py index 4508d1f..ea51c4e 100644 --- a/setup.py +++ b/setup.py @@ -1,41 +1,51 @@ -from setuptools import setup import setuptools +from setuptools import setup -if __name__!="__main__": - print("Please change your plugins folder to the 'active plugins' subfolder") +install_deps = [ + "cellprofiler", + "cellprofiler-core", +] -else: - install_deps = [ - "cellprofiler", - "cellprofiler-core", - ] +cellpose_deps = [ + "cellpose>=1.0.2" +] - cellpose_deps = [ - "cellpose>=1.0.2" - ] +omnipose_deps = [ + "omnipose", + "ncolor" +] - omnipose_deps = [ - "omnipose", - "ncolor" - ] +stardist_deps = [ + "tensorflow", + "stardist" +] - stardist_deps = [ - "tensorflow", - "stardist" - ] +imagejscript_deps = [ + "pyimagej" +] - imagejscript_deps = [ - "pyimagej" - ] +# The zeroc-ice version OMERO needs is very difficult to build, so here are some premade wheels for a bunch of platforms +omero_deps = [ + "zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-macos-x86_64/releases/download/20220722/zeroc_ice-3.6.5-cp310-cp310-macosx_10_15_x86_64.whl ; sys_platform == 'darwin' and python_version == '3.10'", + "zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-macos-x86_64/releases/download/20220722/zeroc_ice-3.6.5-cp39-cp39-macosx_10_15_x86_64.whl ; sys_platform == 'darwin' and python_version == '3.9'", + "zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-macos-x86_64/releases/download/20220722/zeroc_ice-3.6.5-cp38-cp38-macosx_10_15_x86_64.whl ; sys_platform == 'darwin' and python_version == '3.8'", + "zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-ubuntu2204-x86_64/releases/download/20221004/zeroc_ice-3.6.5-cp310-cp310-linux_x86_64.whl ; 'Ubuntu' in platform_version and python_version == '3.10'", + "zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20221003/zeroc_ice-3.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl ; sys_platform == 'linux' and python_version == '3.10' and 'Ubuntu' not in platform_version", + "zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20221003/zeroc_ice-3.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl ; sys_platform == 'linux' and python_version == '3.9'", + "zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20221003/zeroc_ice-3.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl ; sys_platform == 'linux' and python_version == '3.8'", + "omero-py", + "omero-user-token", +] - setup( - name="cellprofiler_plugins", - packages=setuptools.find_packages(), - install_requires = install_deps, - extras_require = { - "cellpose": cellpose_deps, - "omnipose": omnipose_deps, - "stardist": stardist_deps, - "imagejscript": imagejscript_deps, - } - ) +setup( + name="cellprofiler_plugins", + packages=setuptools.find_packages(), + install_requires=install_deps, + extras_require={ + "cellpose": cellpose_deps, + "omnipose": omnipose_deps, + "stardist": stardist_deps, + "imagejscript": imagejscript_deps, + "omero": omero_deps, + }, +)