diff --git a/image_utils.py b/image_utils.py index 8f35294..22ca436 100644 --- a/image_utils.py +++ b/image_utils.py @@ -6,9 +6,29 @@ import modules.common_utils as common_utils import image_utils - -from lvp_logger import logger - +import datetime +from fractions import Fraction + +from lvp_logger import logger, version + +# Conversion to tifffile's desired datatype references +tifffile_dtypes = { + 'BYTE': 1, + 'ASCII': 2, + 'SHORT': 3, + 'LONG': 4, + 'RATIONAL': 5, + 'SBYTE': 6, + 'UNDEFINED': 7, + 'SSHORT': 8, + 'SLONG': 9, + 'SRATIONAL': 10, + 'FLOAT': 11, + 'DOUBLE': 12, + 'SINGLE': 13, + 'QWORD': 16, + 'SQWORD': 17, +} def is_color_image(image) -> bool: if len(image.shape) == 3 and image.shape[2] == 3: @@ -114,7 +134,54 @@ def convert_16bit_to_8bit(image): return (new_image/256).astype('uint8') -def generate_ome_tiff_support_data(data, metadata: dict): +def write_tiff( + data, + file_loc: pathlib.Path, + metadata: dict, + ome: bool, + video_frame: bool = False, + extratags: list = [], +): + + # Note: OpenCV and TIFFFILE have the Red/Blue color planes swapped, so need to swap + # them before writing out to tiff + use_color = image_utils.is_color_image(data) + if use_color: + data = cv2.cvtColor(data, cv2.COLOR_BGR2RGB) + + support_data = generate_tiff_data(data=data, metadata=metadata, ome=ome, video_frame=video_frame) + + if True == ome: + kwargs = { + 'bigtiff': False + } + else: + kwargs = {} + + with tf.TiffWriter(str(file_loc), **kwargs) as tif: + if not video_frame: + tif.write( + data, + resolution=support_data['resolution'], + metadata=support_data['metadata'], + datetime=metadata['datetime'], + software=f"LumaViewPro {version}", + **support_data['options'], + ) + else: + tif.write( + data, + metadata=support_data['metadata'], + datetime=metadata['datetime'], + software=f"LumaViewPro {version}", + **support_data['options'], + ) + + +def generate_tiff_data(data, metadata: dict, ome: bool, video_frame: bool): + + dtype = tifffile_dtypes + use_color = image_utils.is_color_image(data) if use_color: @@ -124,29 +191,145 @@ def generate_ome_tiff_support_data(data, metadata: dict): photometric = 'minisblack' axes = 'YX' - ome_metadata={ - 'axes': axes, - 'SignificantBits': data.itemsize*8, - 'PhysicalSizeX': metadata['pixel_size_um'], - 'PhysicalSizeXUnit': 'µm', - 'PhysicalSizeY': metadata['pixel_size_um'], - 'PhysicalSizeYUnit': 'µm', - 'Channel': {'Name': [metadata['channel']]}, - 'Plane': { - 'PositionX': metadata['plate_pos_mm']['x'], - 'PositionY': metadata['plate_pos_mm']['y'], - 'PositionZ': metadata['z_pos_um'], - 'PositionXUnit': 'mm', - 'PositionYUnit': 'mm', - 'PositionZUnit': 'um', - 'ExposureTime': metadata['exposure_time_ms'], - 'ExposureTimeUnit': 'ms', - 'Gain': metadata['gain_db'], - 'GainUnit': 'dB', - 'Illumination': metadata['illumination_ma'], - 'IlluminationUnit': 'mA' + """ + To Add: + ImageNumber + LensModel + + """ + if True == ome: + tiff_metadata={ + 'axes': axes, + 'SignificantBits': data.itemsize*8, + 'PhysicalSizeX': metadata['pixel_size_um'], + 'PhysicalSizeXUnit': 'µm', + 'PhysicalSizeY': metadata['pixel_size_um'], + 'PhysicalSizeYUnit': 'µm', + 'Channel': {'Name': [metadata['channel']]}, + 'Plane': { + 'PositionX': metadata['plate_pos_mm']['x'], + 'PositionY': metadata['plate_pos_mm']['y'], + 'PositionZ': metadata['z_pos_um'], + 'PositionXUnit': 'mm', + 'PositionYUnit': 'mm', + 'PositionZUnit': 'um', + 'ExposureTime': metadata['exposure_time_ms'], + 'ExposureTimeUnit': 'ms', + 'Gain': metadata['gain_db'], + 'GainUnit': 'dB', + 'Illumination': metadata['illumination_ma'], + 'IlluminationUnit': 'mA' + } } - } + tiff_extratags = [] + # Metadata seems to be working properly for OME-TIFF's so let's leave it alone for now. + + else: + if not video_frame: + tiff_metadata={ + "CameraMake": metadata['camera_make'], + "ExposureTime": metadata['exposure_time_ms'], + "ISOSpeed": metadata['gain_db'], + "DateTime": metadata['datetime'], + "Software": metadata['software'], + "XPosition": metadata['x_pos'], + "YPosition": metadata['y_pos'], + "SubjectDistance": metadata['z_pos_um'], + "SubSecTime": metadata['sub_sec_time'], + "Channel": metadata['channel'], + "BrightnessValue": metadata['illumination_ma'] + } + # extratags: + # Additional tags to write. A list of tuples with 5 items: + # + # 0. code (int): Tag Id. + # + # 1. dtype (:py:class:`DATATYPE`): + # Data type of items in `value`. + # + # 2. count (int): Number of data values. + # Not used for string or bytes values. + # + # 3. value (Sequence[Any]): `count` values compatible with + # `dtype`. Bytes must contain count values of dtype packed + # as binary data. + # + # 4. writeonce (bool): If *True*, write tag to first page + # of a series only. + # + # Duplicate and select tags in TIFF.TAG_FILTERED are not written + # if the extratag is specified by integer code. + # + # Extratags cannot be used to write IFD type tags. + # + # Format: (tag_number, datatype, count, value, write_ifd) + # For rational number values: (numerator, denominator) + tiff_extratags = [] + """tiff_extratags = [ + # CameraMake: Tag ID 271, 'ASCII' + (271, dtype['ASCII'], len(metadata['camera_make']) + 1, metadata['camera_make'], False), + + + # ExposureTime: Tag ID 33434, 'RATIONAL' + (33434, dtype['RATIONAL'], 1, ms_exposure_to_rational(metadata['exposure_time_ms']), False), + + + # ISOSpeed: Tag ID 34867, 'double' + # Using in place of GainControl (Improper use of GainControl) + (34867, dtype['DOUBLE'], 1, metadata['gain_db'], False), + + # DateTime: Tag ID 306, 'ASCII' + (306, dtype['ASCII'], len(metadata['datetime']) + 1, metadata['datetime'], False), + + # SubjectDistance: Tag ID 37386, 'RATIONAL' + (37386, dtype['RATIONAL'], 1, subject_dist_to_rational(metadata['z_pos_um']), False), + + # SubSecTime: Tag ID 37520, 'ASCII' + (37520, dtype['ASCII'], len(metadata['sub_sec_time']) + 1, metadata['sub_sec_time'], False), + + # Channel: Tag ID 65001, 'ASCII' **CUSTOM** + (65001, dtype['ASCII'], len(metadata['channel']) + 1, metadata['channel'], False), + + # BrightnessValue: Tag ID 37393, 'SRATIONAL' + (37393, dtype['SRATIONAL'], 1, (metadata['illumination_ma'], 1), False)]""" + + """ + # XPosition: Tag ID 65001, 'RATIONAL' + # Need to double check units + (286, dtype['RATIONAL'], 1, (metadata['x_pos'], 1), False),] + + + # YPosition: Custom Tag ID 65002, 'RATIONAL' + # Need to double check units + (287, dtype['RATIONAL'], 1, (metadata['y_pos'], 1), False), + + + """ + + + + else: + # Video Frame + # Add further parameters in the future if testing goes well + + """date_time_data = metadata['timestamp'].strftime("%Y:%m:%d %H:%M:%S") + sub_sec_time = f"{metadata['timestamp'].microsecond // 1000:03d}" + + tiff_extratags = [ + # Tag 306: DateTime (ASCII) + (306, 'ascii', len(date_time_data) + 1, date_time_data, False), + + # Tag 37520: SubSecTime (ASCII) + (37520, 'ascii', len(sub_sec_time) + 1, sub_sec_time, False), + + # Tag 37393: ImageNumber (long) + (37393, 'long', 1, metadata['frame_num'], False) + + ]""" + tiff_extratags = [] + tiff_metadata = metadata + + options=dict( photometric=photometric, @@ -156,41 +339,33 @@ def generate_ome_tiff_support_data(data, metadata: dict): maxworkers=2 ) - resolution = (1e4 / metadata['pixel_size_um'], 1e4 / metadata['pixel_size_um']) - - return { - 'metadata': ome_metadata, - 'options': options, - 'resolution': resolution, - } + if not video_frame: + resolution = (1e4 / metadata['pixel_size_um'], 1e4 / metadata['pixel_size_um']) + return { + 'metadata': tiff_metadata, + 'extratags': tiff_extratags, + 'options': options, + 'resolution': resolution, + } + + else: + return { + 'metadata': tiff_metadata, + 'extratags': tiff_extratags, + 'options': options, + } -def write_ome_tiff( - data, - file_loc: pathlib.Path, - metadata: dict, -): - # Note: OpenCV and TIFFFILE have the Red/Blue color planes swapped, so need to swap - # them before writing out to OME tiff - use_color = image_utils.is_color_image(data) - if use_color: - data = cv2.cvtColor(data, cv2.COLOR_BGR2RGB) +def ms_exposure_to_rational(ms_exposure): + exposure_seconds = ms_exposure / 1000 + fraction = Fraction(exposure_seconds).limit_denominator(1_000_000) + # Metadata uses rational number of seconds + return fraction.numerator, fraction.denominator - # if use_color: - # photometric = 'rgb' - # axes = 'YXS' - # else: - # photometric = 'minisblack' - # axes = 'YX' - ome_tiff_support_data = generate_ome_tiff_support_data(data=data, metadata=metadata) - - with tf.TiffWriter(str(file_loc), bigtiff=False) as tif: - tif.write( - data, - resolution=ome_tiff_support_data['resolution'], - metadata=ome_tiff_support_data['metadata'], - **ome_tiff_support_data['options'] - ) +def subject_dist_to_rational(distance): + distance_meters = distance / 1_000_000 # Convert um to m + fraction = Fraction(distance_meters).limit_denominator(1_000_000) + return fraction.numerator, fraction.denominator def add_scale_bar( diff --git a/lumascope_api.py b/lumascope_api.py index f167c5b..19e2999 100644 --- a/lumascope_api.py +++ b/lumascope_api.py @@ -42,7 +42,7 @@ from pyloncamera import PylonCamera # Import additional libraries -from lvp_logger import logger +from lvp_logger import logger, version import modules.common_utils as common_utils import modules.coord_transformations as coord_transformations import modules.objectives_loader as objectives_loader @@ -398,9 +398,15 @@ def _validate(): ) metadata = { + 'camera_make': 'Etaluma', + 'software': f'LumaViewPro {version}', 'channel': color, + 'datetime': datetime.datetime.now().strftime("%Y:%m:%d %H:%M:%S"), # Format for metadata + 'sub_sec_time': f"{datetime.datetime.now().microsecond // 1000:03d}", 'focal_length': self._objective['focal_length'], 'plate_pos_mm': {'x': px, 'y': py}, + 'x_pos': px, + 'y_pos': py, 'z_pos_um': z, 'exposure_time_ms': round(self.get_exposure_time(), common_utils.max_decimal_precision('exposure')), 'gain_db': round(self.get_gain(), common_utils.max_decimal_precision('gain')), @@ -476,15 +482,18 @@ def save_image( metadata = image_data['metadata'] file_loc = metadata['file_loc'] + if output_format == 'OME-TIFF': + ome=True + else: + ome=False + try: - if output_format == 'OME-TIFF': - image_utils.write_ome_tiff( - data=image, - file_loc=file_loc, - metadata=metadata, - ) - else: - cv2.imwrite(str(file_loc), image.astype(array.dtype)) + image_utils.write_tiff( + data=image, + file_loc=file_loc, + metadata=metadata, + ome=ome, + ) logger.info(f'[SCOPE API ] Saving Image to {file_loc}') except: diff --git a/lumaviewpro.py b/lumaviewpro.py index 1404f8f..7df98dd 100644 --- a/lumaviewpro.py +++ b/lumaviewpro.py @@ -1328,8 +1328,22 @@ def recording_complete(self, dt): output_file_loc = save_folder / f"{frame_name}.tiff" - if not cv2.imwrite(filename=str(output_file_loc), img=image): - logger.error(f"Manual-Video] Failed to write frame {frame_num}") + metadata = { + "datetime": ts.strftime("%Y:%m:%d %H:%M:%S"), + "timestamp": ts.strftime("%Y:%m:%d %H:%M:%S.%f"), + "frame_num": frame_num + } + + try: + image_utils.write_tiff( + data=image, + metadata=metadata, + file_loc=output_file_loc, + video_frame=True, + ome=False, + ) + except Exception as e: + logger.error(f"Protocol-Video] Failed to write frame {frame_num}: {e}") else: if not self.video_save_folder.exists(): diff --git a/modules/sequenced_capture_executor.py b/modules/sequenced_capture_executor.py index ee0b9df..d6106a1 100644 --- a/modules/sequenced_capture_executor.py +++ b/modules/sequenced_capture_executor.py @@ -745,9 +745,26 @@ def _capture( output_file_loc = save_folder / f"{frame_name}.tiff" - if not cv2.imwrite(filename=str(output_file_loc), img=image): + metadata = { + "datetime": ts.strftime("%Y:%m:%d %H:%M:%S"), + "timestamp": ts.strftime("%Y:%m:%d %H:%M:%S.%f"), + "frame_num": frame_num + } + + try: + image_utils.write_tiff( + data=image, + metadata=metadata, + file_loc=output_file_loc, + video_frame=True, + ome=False, + ) + except Exception as e: + logger.error(f"Protocol-Video] Failed to write frame {frame_num}: {e}") + + """if not cv2.imwrite(filename=str(output_file_loc), img=image): logger.error(f"Protocol-Video] Failed to write frame {frame_num}") - +""" else: video_writer = VideoWriter( output_file_loc=output_file_loc, diff --git a/pyloncamera.py b/pyloncamera.py index 972e4e5..b685529 100644 --- a/pyloncamera.py +++ b/pyloncamera.py @@ -36,6 +36,7 @@ import contextlib import datetime +import os import numpy as np from pypylon import pylon, genicam @@ -49,6 +50,13 @@ def __init__(self, **kwargs): self.error_report_count = 0 self.array = np.array([]) self.cam_image_handler = None + + if os.getenv("PYLON_CAMEMU", None) != None: + logger.info('[CAM Class ] PylonCamera.connect() detected request to use camera emulation') + self._use_camera_emulation = True + else: + self._use_camera_emulation = False + self.connect() def __delete__(self): @@ -136,7 +144,8 @@ def init_camera_config(self): self.set_pixel_format(pixel_format='Mono8') self.auto_gain(state=False) camera.ReverseX.SetValue(True) - self.init_auto_gain_focus() + if not self._use_camera_emulation: + self.init_auto_gain_focus() self.exposure_t(t=10) self.set_frame_size(w=1900, h=1900) diff --git a/requirements.txt b/requirements.txt index 1c3a85a..4da8044 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,5 +14,5 @@ pypylon==3.0.1 pyserial==3.5 scikit-image==0.22.0 scipy==1.12.0 -tifffile==2024.2.12 +tifffile==2024.9.20 userpaths==0.1.3 diff --git a/version.txt b/version.txt index 7ec1d6d..a37af2c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.1.0 +2.2.0-beta