diff --git a/README.rst b/README.rst index 115969cdb..d86f84a8b 100644 --- a/README.rst +++ b/README.rst @@ -70,16 +70,20 @@ Optional: To change the maximum active channel count from the default of 10: Now restart OMERO.web as normal. -Enabling figure export ----------------------- +Enabling figure export from OMERO +--------------------------------- This section assumes that an OMERO.server is already installed. Figures can be exported as PDF or TIFF files using a script that runs on the OMERO.server. This script needs to be uploaded to the OMERO.server and its dependencies installed in the OMERO.server virtual environment. -The script can be uploaded using two alternative workflows, both of which require you to have the correct admin privileges. -To find where OMERO.figure has been installed using pip, run: +The script can be uploaded using various workflows, all of which require you to have the correct admin privileges. + +*Option 1*: Log in to the webclient as an Admin and open the OMERO.figure app. If the OMERO script is not found or is not up to date, you will +see a warning message with a button to upload the script. Click the button to upload the script from the OMERO.figure app. + +*Option 2*: Upload the script from the installation directory. To find where OMERO.figure has been installed using pip, run: :: @@ -87,19 +91,16 @@ To find where OMERO.figure has been installed using pip, run: The command will display the absolute path to the directory where the application is installed e.g. ``~//lib/python3.6/site-packages``. Go to that directory. -*Option 1*: Connect to the OMERO server and upload the script via the CLI. It is important to be in the correct directory when uploading so that the script is uploaded with the full path: ``omero/figure_scripts/Figure_To_Pdf.py``: +Connect to the OMERO server and upload the script via the CLI. It is important to be in the correct directory when uploading so that the script is uploaded with the full path: ``omero/figure_scripts/Figure_To_Pdf.py``: :: $ cd omero_figure/scripts $ omero script upload omero/figure_scripts/Figure_To_Pdf.py --official -*Option 2*: Alternatively, before starting the OMERO.server, copy the script from the figure install +*Option 3*: Alternatively, before starting the OMERO.server, copy the script from the figure install ``/omero_figure/scripts/omero/figure_scripts/Figure_To_Pdf.py`` to the OMERO.server ``path/to/OMERO.server/lib/scripts/omero/figure_scripts``. Then restart the OMERO.server. -*Option 3*: Upload the script through the OMERO web interface: For this, log into your OMERO web interface as admin, select the scripts icon and click on the "Upload Script" button. -Select the ``Figure_To_Pdf.py`` script from the directory where you copied it to locally and upload it into the directory ``omero/figure_scripts``. - Now install the script's dependencies: @@ -117,6 +118,34 @@ Now install the script's dependencies: $ pip install markdown + +Run Figure export locally +------------------------- + +If your figure contains only OME-Zarr images (no images from OMERO), then +the export script can be run locally to convert a figure JSON file to PDF or TIFF. +NB: the OME-Zarr URLs must be accessible from the machine where the export script is run. +NB: channel LUTs are not currently supported when rendering OME-Zarr images for PDF or TIFFs. +Any LUTs will be rendered with white color. + +Download the figure JSON (File > Save, in the standalone app) then install the export script. +Here, we create a new conda environment and install the export script: + +:: + + $ conda create --name figure_export python=3.12 + $ conda activate figure_export + $ pip install "omero-figure[export]" + +To export the figure as PDF or TIFF, run the script with the path to the figure JSON and the output file path as arguments: +Use the ``.pdf`` extension for PDF export and ``.tiff`` for TIFF export. This example exports the +downloaded `figure_json/my_figure.json` to `my_figure.pdf` in the current directory: + +:: + + $ figure_export figure_json/my_figure.json my_figure.pdf + + Upgrading OMERO.figure ---------------------- diff --git a/omero_figure/cli.py b/omero_figure/cli.py new file mode 100644 index 000000000..0f4245bcc --- /dev/null +++ b/omero_figure/cli.py @@ -0,0 +1,63 @@ +# +# Copyright (c) 2014-2026 University of Dundee. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +import argparse +import os + +from omero_figure.scripts.omero.figure_scripts.Figure_To_Pdf \ + import export_figure + + +def figure_export() -> None: + parser = argparse.ArgumentParser( + prog="figure_export", + description="Export an OMERO Figure file.", + ) + parser.add_argument("input", type=str, help="Path to the input file") + parser.add_argument("output", type=str, help="Path to the output file") + + args = parser.parse_args() + output_path = args.output + print(f"input: {args.input}") + print(f"output: {args.output}") + + if os.path.exists(output_path): + print(f"Output file {output_path} already exists; Exiting.") + return + + # open and read the input file + with open(args.input, 'r') as f: + figure_json = f.read() + + fext = output_path.split('.')[-1].lower() + file_type = "TIFF" if fext in ['tif', 'tiff'] else "PDF" + + script_args = { + "Figure_JSON": figure_json, + "outputPathName": output_path, + "Export_Option": file_type, + # TODO: Fix URL + "Webclient_URI": "http://localhost:8080/" + } + + export_figure(None, script_args) + + print("Figure export completed.") + + +if __name__ == "__main__": + figure_export() diff --git a/omero_figure/scripts/__init__.py b/omero_figure/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/omero_figure/scripts/omero/__init__.py b/omero_figure/scripts/omero/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/omero_figure/scripts/omero/figure_scripts/Figure_To_Pdf.py b/omero_figure/scripts/omero/figure_scripts/Figure_To_Pdf.py index 4158bcc7e..c602e548a 100644 --- a/omero_figure/scripts/omero/figure_scripts/Figure_To_Pdf.py +++ b/omero_figure/scripts/omero/figure_scripts/Figure_To_Pdf.py @@ -30,16 +30,8 @@ from copy import deepcopy import re -from omero.model import ImageAnnotationLinkI, ImageI, LengthI -import omero.scripts as scripts -from omero.gateway import BlitzGateway -from omero.rtypes import rstring, robject -from omero.model.enums import UnitsLength - from io import BytesIO -from reportlab.pdfbase.pdfmetrics import stringWidth - try: from PIL import Image, ImageDraw, ImageFont except ImportError: @@ -48,6 +40,19 @@ logger = logging.getLogger('figure_to_pdf') +omero_installed = True +try: + from omero.model import ImageAnnotationLinkI, ImageI, LengthI + import omero.scripts as scripts + from omero.gateway import BlitzGateway + from omero.rtypes import rstring, robject + from omero.model.enums import UnitsLength + from Glacier2 import PermissionDeniedException + from Ice import ConnectionRefusedException +except ImportError: + omero_installed = False + logger.info("OMERO libraries not installed.") + try: import markdown @@ -64,6 +69,7 @@ from reportlab.platypus import Paragraph from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT from reportlab.lib.utils import ImageReader + from reportlab.pdfbase.pdfmetrics import stringWidth reportlab_installed = True except ImportError: reportlab_installed = False @@ -96,17 +102,27 @@ """ # Create a dict we can use for scalebar unit conversions -unit_symbols = {} -for name in LengthI.SYMBOLS.keys(): - if name in ("PIXEL", "REFERENCEFRAME"): - continue - klass = getattr(UnitsLength, name) - unit = LengthI(1, klass) - to_microns = LengthI(unit, UnitsLength.MICROMETER) - unit_symbols[name] = { - 'symbol': unit.getSymbol(), - 'microns': to_microns.getValue() - } +unit_symbols = { + "ANGSTROM": {'symbol': "\u00c5", 'microns': 0.0001}, + "CENTIMETER": {'symbol': "cm", 'microns': 10000.0}, + "KILOMETER": {'symbol': "km", 'microns': 1000000000.0}, + "METER": {'symbol': "m", 'microns': 1000000.0}, + "MICROMETER": {'symbol': "\u00b5m", 'microns': 1}, + "MILLIMETER": {'symbol': "mm", 'microns': 1000.0}, + "NANOMETER": {'symbol': "nm", 'microns': 0.001}, +} +if omero_installed: + units_symbols = {} + for name in LengthI.SYMBOLS.keys(): + if name in ("PIXEL", "REFERENCEFRAME"): + continue + klass = getattr(UnitsLength, name) + unit = LengthI(1, klass) + to_microns = LengthI(unit, UnitsLength.MICROMETER) + unit_symbols[name] = { + 'symbol': unit.getSymbol(), + 'microns': to_microns.getValue() + } def scale_to_export_dpi(pixels): @@ -652,11 +668,36 @@ def __init__(self, pil_img, panel, crop): self.scale = pil_img.size[0] / crop['width'] self.draw = ImageDraw.Draw(pil_img) - from omero.gateway import THISPATH - self.GATEWAYPATH = THISPATH + if omero_installed: + from omero.gateway import THISPATH + self.FONTPATH = os.path.join(THISPATH, "pilfonts") + else: + # get location of this script... /pilfonts + this_path = os.path.dirname(os.path.abspath(__file__)) + self.FONTPATH = os.path.join(this_path, "pilfonts") super(ShapeToPilExport, self).__init__(panel) + def get_font(self, fontsize, bold=False, italics=False): + """ Try to load font from known location in OMERO or local """ + font_name = "FreeSans.ttf" + if bold and italics: + font_name = "FreeSansBoldOblique.ttf" + elif bold: + font_name = "FreeSansBold.ttf" + elif italics: + font_name = "FreeSansOblique.ttf" + path_to_font = os.path.join(self.FONTPATH, font_name) + try: + font = ImageFont.truetype(path_to_font, fontsize) + except Exception: + try: + font_path = os.path.join(self.FONTPATH, "B24.pil") + font = ImageFont.load(font_path) + except Exception: + font = ImageFont.load_default() + return font + def get_panel_coords(self, shape_x, shape_y): """ Convert coordinate from the image onto the panel. @@ -711,13 +752,7 @@ def draw_shape_label(self, shape, bounds): r, g, b, a = self.get_rgba_int(shape['strokeColor']) # bump up alpha a bit to make text more readable rgba = (r, g, b, int(128 + a / 2)) - font_name = "FreeSans.ttf" - path_to_font = os.path.join(self.GATEWAYPATH, "pilfonts", font_name) - try: - font = ImageFont.truetype(path_to_font, size) - except Exception: - font = ImageFont.load( - '%s/pilfonts/B%0.2d.pil' % (self.GATEWAYPATH, size)) + font = self.get_font(size) box = font.getbbox(text) width = box[2] - box[0] height = box[3] - box[1] @@ -738,15 +773,7 @@ def draw_text(self, shape): x, y = text_coords['x'], text_coords['y'] r, g, b, a = self.get_rgba_int(stroke_color) - - font_name = "FreeSans.ttf" - path_to_font = os.path.join(self.GATEWAYPATH, "pilfonts", font_name) - try: - font = ImageFont.truetype(path_to_font, font_size) - except Exception: - font = ImageFont.load( - '%s/pilfonts/B%0.2d.pil' % (self.GATEWAYPATH, font_size)) - + font = self.get_font(font_size) box = font.getbbox(text) txt_w = box[2] - box[0] box = font.getbbox("Mg") # height including acsenders & descenders @@ -1032,6 +1059,8 @@ def __init__(self, conn, script_params, export_images=False): self.conn = conn self.script_params = script_params self.export_images = export_images + # For standalone script, we may have relative or absolute output path + self.output_path_name = script_params.get("outputPathName") self.ns = "omero.web.figure.pdf" self.mimetype = "application/pdf" @@ -1123,18 +1152,22 @@ def get_figure_file_name(self, page=None): # Extension is pdf or tiff fext = self.get_figure_file_ext() - name = self.figure_name - # in case we have path/to/name, just use name - name = path.basename(name) + # For standalone script, we may have relative or absolute output path + if self.output_path_name is not None: + name = self.output_path_name + else: + # Remove commas: causes problems 'duplicate headers' in download + name = self.figure_name.replace(",", ".") + # in case we have path/to/name, just use name + name = path.basename(name) - # if ends with E.g. .pdf, remove extension - if name.endswith("." + fext): - name = name[0: -len("." + fext)] + # remove extension + for ext in ("pdf", "tiff", "tif"): + if name.endswith("." + ext): + name = name[0: -len("." + ext)] # Name with extension and folder full_name = "%s.%s" % (name, fext) - # Remove commas: causes problems 'duplicate headers' in file download - full_name = full_name.replace(",", ".") index = page if page is not None else 1 if fext == "tiff" and self.page_count > 1: @@ -1885,6 +1918,10 @@ def get_color_ramp(self, channel): """ color = channel["color"] + # If LUT but no OMERO conn, we return a greyscale ramp + if self.conn is None and color.endswith(".lut"): + color = "FFFFFF" + # Convert the hexadecimal string to RGB color_ramp = None if len(color) == 6: @@ -2397,7 +2434,12 @@ def render_plane(dask_data, t, c, z, window=None): the_z = panel['theZ'] the_t = panel['theT'] for ch_index, ch in enumerate(channels): + if not ch['active']: + continue hex_color = ch['color'] + if "lut" in hex_color: + # LUTs not supported for Zarr images yet + hex_color = "FFFFFF" r = int(hex_color[0:2], 16) g = int(hex_color[2:4], 16) b = int(hex_color[4:6], 16) @@ -2910,9 +2952,13 @@ def __init__(self, conn, script_params, export_images=None): super(TiffExport, self).__init__(conn, script_params, export_images) - from omero.gateway import THISPATH - self.GATEWAYPATH = THISPATH - + if omero_installed: + from omero.gateway import THISPATH + self.FONTPATH = os.path.join(THISPATH, "pilfonts") + else: + # get location of this script... /pilfonts + this_path = os.path.dirname(os.path.abspath(__file__)) + self.FONTPATH = os.path.join(this_path, "pilfonts") self.ns = "omero.web.figure.tiff" self.mimetype = "image/tiff" @@ -2929,12 +2975,15 @@ def get_font(self, fontsize, bold=False, italics=False): font_name = "FreeSansBold.ttf" elif italics: font_name = "FreeSansOblique.ttf" - path_to_font = os.path.join(self.GATEWAYPATH, "pilfonts", font_name) + path_to_font = os.path.join(self.FONTPATH, font_name) try: font = ImageFont.truetype(path_to_font, fontsize) except Exception: - font = ImageFont.load( - '%s/pilfonts/B%0.2d.pil' % (self.GATEWAYPATH, 24)) + try: + font_path = os.path.join(self.FONTPATH, "B24.pil") + font = ImageFont.load(font_path) + except Exception: + font = ImageFont.load_default() return font def get_figure_file_ext(self): @@ -3446,5 +3495,68 @@ def run_script(): client.closeSession() +# usage: +# python omero_figure/export_script/figure_to_pdf.py + +def handle_main(): + + try: + if omero_installed: + # normal script workflow - uses OMERO connection + run_script() + + # If script ran successfully, we're done! + return + except (PermissionDeniedException, ConnectionRefusedException): + # This is a workaround for the fact that the script is not run in a + # session, so we need to create one manually. + + print("ClientError: Could not connect to OMERO server.") + + # argparse to allow testing without OMERO + import argparse + parser = argparse.ArgumentParser(description='Test Figure to PDF export') + parser.add_argument("file", help="Path to Figure JSON file") + parser.add_argument('outputPathName', + help=("Relative or absolute path/to/output.pdf. " + "Extension is used to set export file type")) + parser.add_argument('--omero', action='store_true', + help='Run with OMERO connection') + args = parser.parse_args() + + fpath = args.file + with open(fpath, 'r') as f: + figure_json = json.load(f) + + output_path_name = args.outputPathName + fext = output_path_name.split('.')[-1].lower() + file_type = "TIFF" if fext in ['tif', 'tiff'] else "PDF" + + script_args = { + "Figure_JSON": json.dumps(figure_json), + "Export_Option": file_type, + "outputPathName": output_path_name, + "Webclient_URI": "http://localhost/webclient/" + } + + starttime = datetime.now() + if args.omero: + print("TESTING: Running with OMERO....") + if not omero_installed: + print("omero-py not installed.") + return + + from omero.cli import cli_login + with cli_login() as cli: + conn = BlitzGateway(client_obj=cli.get_client()) + export_figure(conn, script_args) + else: + print("Running without OMERO....") + export_figure(None, script_args) + + endtime = datetime.now() + print(f"Elapsed time: {endtime - starttime}") + + if __name__ == "__main__": - run_script() + handle_main() diff --git a/omero_figure/scripts/omero/figure_scripts/__init__.py b/omero_figure/scripts/omero/figure_scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/omero_figure/scripts/omero/figure_scripts/pilfonts/B24.pbm b/omero_figure/scripts/omero/figure_scripts/pilfonts/B24.pbm new file mode 100644 index 000000000..4c87027d3 Binary files /dev/null and b/omero_figure/scripts/omero/figure_scripts/pilfonts/B24.pbm differ diff --git a/omero_figure/scripts/omero/figure_scripts/pilfonts/B24.pil b/omero_figure/scripts/omero/figure_scripts/pilfonts/B24.pil new file mode 100644 index 000000000..931b479bb Binary files /dev/null and b/omero_figure/scripts/omero/figure_scripts/pilfonts/B24.pil differ diff --git a/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSans.ttf b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSans.ttf new file mode 100644 index 000000000..9d5134705 Binary files /dev/null and b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSans.ttf differ diff --git a/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansBold.ttf b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansBold.ttf new file mode 100755 index 000000000..63644e743 Binary files /dev/null and b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansBold.ttf differ diff --git a/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansBoldOblique.ttf b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansBoldOblique.ttf new file mode 100755 index 000000000..dde7f32f8 Binary files /dev/null and b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansBoldOblique.ttf differ diff --git a/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansOblique.ttf b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansOblique.ttf new file mode 100755 index 000000000..745288572 Binary files /dev/null and b/omero_figure/scripts/omero/figure_scripts/pilfonts/FreeSansOblique.ttf differ diff --git a/setup.py b/setup.py index fca8df84c..cff6fab53 100644 --- a/setup.py +++ b/setup.py @@ -80,7 +80,11 @@ def run(self): url=HOMEPAGE, download_url='%s/archive/v%s.tar.gz' % (HOMEPAGE, VERSION), keywords=['OMERO.web', 'figure'], - install_requires=['omero-web>=5.6.0'], + extras_require={ + 'omero': ['omero-web>=5.6.0'], + 'export': ['pillow', 'reportlab', 'markdown', 'zarr', + 'dask', 'fsspec[http]'], + }, python_requires='>=3', include_package_data=True, zip_safe=False, @@ -90,5 +94,10 @@ def run(self): 'sdist': require_npm(sdist, True), 'develop': require_npm(develop), }, + entry_points={ + 'console_scripts': [ + 'figure_export=omero_figure.cli:figure_export', + ], + }, tests_require=['pytest'], )