diff --git a/.isort.cfg b/.isort.cfg index 1bd634a4..0f0af7f6 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,2 @@ [settings] -known_third_party = dask,numpy,ome_zarr,omero,omero_rois,omero_zarr,pytest,setuptools,skimage,zarr +known_third_party = dask,numpy,ome_zarr,omero,omero_rois,omero_sys_ParametersI,omero_zarr,pytest,setuptools,skimage,zarr diff --git a/src/omero_zarr/cli.py b/src/omero_zarr/cli.py index ae279b25..9d28a542 100644 --- a/src/omero_zarr/cli.py +++ b/src/omero_zarr/cli.py @@ -29,6 +29,7 @@ from zarr.hierarchy import open_group from zarr.storage import FSStore +from .extinfo import external_info_str, get_extinfo, get_images, set_external_info from .masks import ( MASK_DTYPE_SIZE, MaskSaver, @@ -51,6 +52,7 @@ - masks """ + EXPORT_HELP = """Export an image in zarr format. Using bioformats2raw @@ -108,6 +110,12 @@ POLYGONS_HELP = """Export ROI Polygons on the Image or Plate in zarr format""" +EXTINFO_HELP = """Get the ExternalInfo path of an ome.zarr image. + +Use --set to set the external info path +or --reset in order to remove the ExternalInfo from the image. +""" + def gateway_required(func: Callable) -> Callable: """ @@ -164,7 +172,7 @@ def _configure(self, parser: Parser) -> None: "--style", choices=("split", "labeled"), default="labeled", - help=("Choice of storage for ROIs [breaks ome-zarr]"), + help="Choice of storage for ROIs [breaks ome-zarr]", ) polygons.add_argument( "--label-path", @@ -177,8 +185,8 @@ def _configure(self, parser: Parser) -> None: polygons.add_argument( "--source-image", help=( - "Path to the multiscales group containing the source image/plate. " - "By default, use the output directory" + "Path to the multiscales group containing the source " + "image/plate. By default, use the output directory" ), default=None, ) @@ -191,7 +199,9 @@ def _configure(self, parser: Parser) -> None: ) polygons.add_argument( "--label-name", - help=("Name of the array that will be stored. Ignored for --style=split"), + help=( + "Name of the array that will be stored. Ignored for " "--style=split" + ), default="0", ) @@ -204,8 +214,8 @@ def _configure(self, parser: Parser) -> None: masks.add_argument( "--source-image", help=( - "Path to the multiscales group containing the source image/plate. " - "By default, use the output directory" + "Path to the multiscales group containing the source " + "image/plate. By default, use the output directory" ), default=None, ) @@ -219,14 +229,16 @@ def _configure(self, parser: Parser) -> None: ) masks.add_argument( "--label-name", - help=("Name of the array that will be stored. Ignored for --style=split"), + help=( + "Name of the array that will be stored. Ignored for " "--style=split" + ), default="0", ) masks.add_argument( "--style", choices=("split", "labeled"), default="labeled", - help=("Choice of storage for ROIs [breaks ome-zarr]"), + help="Choice of storage for ROIs [breaks ome-zarr]", ) masks.add_argument( "--label-bits", @@ -249,13 +261,18 @@ def _configure(self, parser: Parser) -> None: export.add_argument( "--bf", action="store_true", - help="Use bioformats2raw on the server to export images. Requires" - " bioformats2raw 0.3.0 or higher and access to the managed repo.", + help=( + "Use bioformats2raw on the server to export images. Requires " + "bioformats2raw 0.3.0 or higher and access to the managed " + "repo." + ), ) export.add_argument( "--bfpath", - help="Use bioformats2raw on a local copy of a file. Requires" - " bioformats2raw 0.4.0 or higher.", + help=( + "Use bioformats2raw on a local copy of a file. Requires " + "bioformats2raw 0.4.0 or higher." + ), ) export.add_argument( "--tile_width", @@ -270,13 +287,15 @@ def _configure(self, parser: Parser) -> None: export.add_argument( "--resolutions", default=None, - help="Number of pyramid resolutions to generate" - " (only for use with bioformats2raw)", + help=( + "Number of pyramid resolutions to generate " + "(only for use with bioformats2raw)" + ), ) export.add_argument( "--max_workers", default=None, - help="Maximum number of workers (only for use with bioformats2raw)", + help=("Maximum number of workers (only for " "use with bioformats2raw)"), ) export.add_argument( "object", @@ -284,9 +303,54 @@ def _configure(self, parser: Parser) -> None: help="The Image to export.", ) + extinfo = parser.add(sub, self.extinfo, EXTINFO_HELP) + extinfo.add_argument( + "object", + type=ProxyStringType(), + help="Object in Class:ID format", + ) + extinfo.add_argument( + "--set", + action="store_true", + help="Set the ExternalInfo path", + ) + extinfo.add_argument( + "--zarrPath", + default=None, + help=( + "Use a specific path (default: Determine from " + "clientPath) (only used in combination with --set)" + ), + ) + extinfo.add_argument( + "--entityType", + default="com.glencoesoftware.ngff:multiscales", + help=( + "Use a specific entityType (default: " + "com.glencoesoftware.ngff:multiscales) " + "(only used in combination with --set)" + ), + ) + extinfo.add_argument( + "--entityId", + default="3", + help=( + "Use a specific entityId (default: 3) " + "(only used in combination with --set)" + ), + ) + extinfo.add_argument( + "--reset", + action="store_true", + help="Removes the ExternalInfo", + ) + for subcommand in (polygons, masks, export): subcommand.add_argument( - "--output", type=str, default="", help="The output directory" + "--output", + type=str, + default="", + help="The output directory", ) for subcommand in (polygons, masks): subcommand.add_argument( @@ -294,9 +358,11 @@ def _configure(self, parser: Parser) -> None: type=str, default=MaskSaver.OVERLAPS[0], choices=MaskSaver.OVERLAPS, - help="To allow overlapping shapes, use 'dtype_max':" - " All overlapping regions will be set to the" - " max value for the dtype", + help=( + "To allow overlapping shapes, use 'dtype_max': " + "All overlapping regions will be set to the " + "max value for the dtype" + ), ) @gateway_required @@ -335,6 +401,58 @@ def export(self, args: argparse.Namespace) -> None: plate = self._lookup(self.gateway, "Plate", args.object.id) plate_to_zarr(plate, args) + @gateway_required + def extinfo(self, args: argparse.Namespace) -> None: + for img, well, idx in get_images(self.gateway, args.object): + img = img._obj + group_id = img.getDetails().getGroup().id.val + extinfo = get_extinfo(self.gateway, img) + if args.set: + try: + img = set_external_info( + self.gateway, + img, + well, + idx, + args.zarrPath, + args.entityType, + int(args.entityId), + ) + img = self.gateway.getUpdateService().saveAndReturnObject(img) + self.ctx.out( + f"Set ExternalInfo for image ({img.id._val}) " + f"{img.name._val}:\n" + f"{external_info_str(img.details.externalInfo)}" + ) + except Exception as e: + self.ctx.err( + f"Failed to set external info for image " + f"({img.id._val}) {img.name._val}: {e}" + ) + elif args.reset: + if extinfo: + img.details.externalInfo = None + img = self.gateway.getUpdateService().saveAndReturnObject(img) + self.ctx.out( + f"Removed ExternalInfo from image " + f"({img.id._val}) {img.name._val}" + ) + else: + self.ctx.out( + f"Image ({img.id._val}) {img.name._val} " "has no ExternalInfo" + ) + else: + if extinfo: + self.ctx.out( + f"ExternalInfo for image ({img.id._val}) " + f"{img.name._val}:\n" + f"{external_info_str(extinfo)}" + ) + else: + self.ctx.out( + f"Image ({img.id._val}) {img.name._val} has no " "ExternalInfo" + ) + def _lookup( self, gateway: BlitzGateway, otype: str, oid: int ) -> BlitzObjectWrapper: diff --git a/src/omero_zarr/extinfo.py b/src/omero_zarr/extinfo.py new file mode 100644 index 00000000..ec474db2 --- /dev/null +++ b/src/omero_zarr/extinfo.py @@ -0,0 +1,244 @@ +import re + +from omero.gateway import BlitzGateway, BlitzObjectWrapper, ImageWrapper +from omero.model import Dataset, ExternalInfoI, Image, ImageI, Plate, Project, Screen +from omero.rtypes import rlong, rstring +from omero_sys_ParametersI import ParametersI + +# Regex to match well positions (eg. A1) +WELL_POS_RE = re.compile(r"(?P\D+)(?P\d+)") +# Regex to match the metadata.xml (could be any xml under xyz.zarr/ directory, +# not only xyz.zarr/OME/METADATA.ome.xml) +METADATA_XML_RE = re.compile(r".+\.zarr\/(.+\.xml)") + + +def get_extinfo(conn: BlitzGateway, image: ImageWrapper) -> ExternalInfoI: + """ + Get the external info for an OMERO image. + + Args: + conn (BlitzGateway): Active OMERO gateway connection + image (ImageWrapper): OMERO image + + Returns: + ExternalInfoI: External info object + + Raises: + Exception: If the query fails. + """ + + details = image.getDetails() + if details and details._externalInfo: + params = ParametersI() + params.addId(details._externalInfo._id) + query = """ + select e from ExternalInfo as e + where e.id = :id + """ + conn.SERVICE_OPTS.setOmeroGroup("-1") + extinfo = conn.getQueryService().findByQuery(query, params, conn.SERVICE_OPTS) + return extinfo + return None + + +def _get_path(conn: BlitzGateway, image_id: int) -> str: + """ + Retrieve the (first) original file path for a given OMERO image. + + Args: + conn (BlitzGateway): Active OMERO gateway connection + image_id (int): OMERO image id + + Returns: + str: path of the image file + + Raises: + Exception: If the query fails. + """ + params = ParametersI() + params.addId(image_id) + query = """ + select fs from Fileset as fs + join fetch fs.images as image + left outer join fetch fs.usedFiles as usedFile + join fetch usedFile.originalFile as f + join fetch f.hasher + where image.id = :id + """ + conn.SERVICE_OPTS.setOmeroGroup("-1") + fs = conn.getQueryService().findByQuery(query, params, conn.SERVICE_OPTS) + path = fs._getUsedFiles()[0]._clientPath._val + return path + + +def _lookup(conn: BlitzGateway, type: str, oid: int) -> BlitzObjectWrapper: + """ + Look up an OMERO object by its type and ID. + + Args: + conn (BlitzGateway): Active OMERO gateway connection + type (str): Type of OMERO object (e.g., "Screen", "Plate", "Image") + oid (int): Object ID to look up + + Returns: + BlitzObjectWrapper: Wrapped OMERO object + + Raises: + ValueError: If the object doesn't exist + """ + conn.SERVICE_OPTS.setOmeroGroup("-1") + obj = conn.getObject(type, oid) + if not obj: + raise ValueError(f"No such {type}: {oid}") + return obj + + +def get_images(conn: BlitzGateway, obj) -> tuple[ImageWrapper, str, int]: + """ + Generator that yields images from any OMERO container object. + + Recursively traverses OMERO container hierarchies + (Screen/Plate/Project/Dataset) to find all contained images. + + Args: + conn (BlitzGateway): Active OMERO gateway connection + obj: OMERO container object (Screen, Plate, Project, Dataset, Image) + or a list of such objects + + Yields: + tuple: Contains: + - ImageWrapper: Image object + - str | None: Well position (eg. A1) if from plate, None otherwise + - int | None: Well sample index if from plate, None otherwise + + Raises: + ValueError: If given an unsupported object type + """ + if isinstance(obj, list): + for x in obj: + yield from get_images(conn, x) + elif isinstance(obj, Screen): + scr = _lookup(conn, "Screen", obj.id) + for plate in scr.listChildren(): + yield from get_images(conn, plate._obj) + elif isinstance(obj, Plate): + plt = _lookup(conn, "Plate", obj.id) + for well in plt.listChildren(): + for idx in range(0, well.countWellSample()): + img = well.getImage(idx) + yield img, well.getWellPos(), idx + elif isinstance(obj, Project): + prj = _lookup(conn, "Project", obj.id) + for ds in prj.listChildren(): + yield from get_images(conn, ds._obj) + elif isinstance(obj, Dataset): + ds = _lookup(conn, "Dataset", obj.id) + for img in ds.listChildren(): + yield img, None, None + elif isinstance(obj, Image): + img = _lookup(conn, "Image", obj.id) + yield img, None, None + else: + raise ValueError(f"Unsupported type: {obj.__class__.__name__}") + + +def set_external_info( + conn: BlitzGateway, + img: ImageI, + well: str, + idx: int, + overwrite_path: str, + entityType: str, + entityId: int, +) -> ImageI: + """ + Set the external info for an OMERO image. + + Args: + conn (BlitzGateway): Active OMERO gateway connection + img (ImageI): OMERO image + well (str | None): Optional well position (e.g. 'A1') + idx (int | None): Optional well sample / field index + overwrite_path (str | None): Optional custom path. If None, path is + derived from image's clientpath. + entityType (str | None): Optional entity type. Defaults to + 'com.glencoesoftware.ngff:multiscales' + entityId (int | None): Optional entity ID. Defaults to 3 + + Returns: + ImageI: Updated OMERO image with external info set + + Raises: + ValueError: If the path cannot be determined from clientpath and no + lsid is provided, or if the well position format is invalid + """ + if not entityType: + entityType = "com.glencoesoftware.ngff:multiscales" + if not entityId: + entityId = 3 + + img_path = _get_path(conn, img.id) + if overwrite_path: + path = overwrite_path + else: + if METADATA_XML_RE.match(img_path): + metadata_xml = METADATA_XML_RE.match(img_path).group(1) + path = img_path.replace(metadata_xml, "") + path = f"/{path}" + else: + raise ValueError(f"Doesn't seem to be an ome.zarr: {img_path}") + + if well: + match = WELL_POS_RE.match(well) + if match: + col = match.group("col") + row = match.group("row") + path = f"{path}{row}/{col}/{idx}" + else: + raise ValueError(f"Couldn't parse well position: {well}") + else: + series = img.getSeries()._val + if not overwrite_path: + path = f"{path}{series}" + + info = ExternalInfoI() + info.entityType = rstring(entityType) + info.entityId = rlong(entityId) + info.lsid = rstring(path) + img.details.externalInfo = info + return img + + +def _checkNone(obj) -> str: + """ + Helper function to safely get string value from OMERO rtype objects. + + Args: + obj: OMERO rtype object that may have a _val attribute + + Returns: + str: The value of obj._val if it exists, otherwise "None" + """ + if obj and obj._val: + return obj._val + return "None" + + +def external_info_str(extinfo: ExternalInfoI) -> str: + """ + Format ExternalInfo object as a human-readable string. + + Args: + extinfo (ExternalInfoI): OMERO ExternalInfo object + + Returns: + str: Formatted string containing entityType, entityId and lsid, + or "None" if extinfo is None + """ + if extinfo: + return ( + f"entityType={_checkNone(extinfo.entityType)}\n" + f"entityId={_checkNone(extinfo.entityId)}\n" + f"lsid={_checkNone(extinfo.lsid)}" + ) + return "None"