Skip to content

References

Convert source images to OME-TIFF or OME-Zarr, with RO-Crate metadata.

DbReader

DbReader

Reads and queries a SQLite database, returning results as dictionaries.

Source code in src/biomero_converter/DbReader.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class DbReader:
    """
    Reads and queries a SQLite database, returning results as dictionaries.
    """

    def __init__(self, db_file):
        """
        Initialize DBReader with a database file.

        Args:
            db_file (str): Path to the SQLite database file.
        """
        self.conn = sqlite3.connect(db_file)
        self.conn.row_factory = DbReader.dict_factory

    @staticmethod
    def dict_factory(cursor, row):
        """
        Converts a database row to a dictionary.

        Args:
            cursor: SQLite cursor object.
            row: Row data.

        Returns:
            dict: Mapping column names to values.
        """
        dct = {}
        for index, column in enumerate(cursor.description):
            dct[column[0]] = row[index]
        return dct

    def fetch_all(self, query, params=[], return_dicts=True):
        """
        Executes a query and fetches all results.

        Args:
            query (str): SQL query string.
            params (list): Query parameters.
            return_dicts (bool): If True, returns list of dicts; else, returns first column values.

        Returns:
            list: Query results.
        """
        cursor = self.conn.cursor()
        cursor.execute(query, params)
        dct = cursor.fetchall()
        if return_dicts:
            values = dct
        else:
            values = [list(row.values())[0] for row in dct]
        return values

    def list_tables(self):
        return self.fetch_all('''SELECT name FROM sqlite_master WHERE type="table"''')

    def close(self):
        """
        Closes the database connection.
        """
        self.conn.close()

conn = sqlite3.connect(db_file) instance-attribute

__init__(db_file)

Initialize DBReader with a database file.

Parameters:

Name Type Description Default
db_file str

Path to the SQLite database file.

required
Source code in src/biomero_converter/DbReader.py
 9
10
11
12
13
14
15
16
17
def __init__(self, db_file):
    """
    Initialize DBReader with a database file.

    Args:
        db_file (str): Path to the SQLite database file.
    """
    self.conn = sqlite3.connect(db_file)
    self.conn.row_factory = DbReader.dict_factory

close()

Closes the database connection.

Source code in src/biomero_converter/DbReader.py
60
61
62
63
64
def close(self):
    """
    Closes the database connection.
    """
    self.conn.close()

dict_factory(cursor, row) staticmethod

Converts a database row to a dictionary.

Parameters:

Name Type Description Default
cursor

SQLite cursor object.

required
row

Row data.

required

Returns:

Name Type Description
dict

Mapping column names to values.

Source code in src/biomero_converter/DbReader.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@staticmethod
def dict_factory(cursor, row):
    """
    Converts a database row to a dictionary.

    Args:
        cursor: SQLite cursor object.
        row: Row data.

    Returns:
        dict: Mapping column names to values.
    """
    dct = {}
    for index, column in enumerate(cursor.description):
        dct[column[0]] = row[index]
    return dct

fetch_all(query, params=[], return_dicts=True)

Executes a query and fetches all results.

Parameters:

Name Type Description Default
query str

SQL query string.

required
params list

Query parameters.

[]
return_dicts bool

If True, returns list of dicts; else, returns first column values.

True

Returns:

Name Type Description
list

Query results.

Source code in src/biomero_converter/DbReader.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def fetch_all(self, query, params=[], return_dicts=True):
    """
    Executes a query and fetches all results.

    Args:
        query (str): SQL query string.
        params (list): Query parameters.
        return_dicts (bool): If True, returns list of dicts; else, returns first column values.

    Returns:
        list: Query results.
    """
    cursor = self.conn.cursor()
    cursor.execute(query, params)
    dct = cursor.fetchall()
    if return_dicts:
        values = dct
    else:
        values = [list(row.values())[0] for row in dct]
    return values

list_tables()

Source code in src/biomero_converter/DbReader.py
57
58
def list_tables(self):
    return self.fetch_all('''SELECT name FROM sqlite_master WHERE type="table"''')

DicomSource

IMAGECODECS_DECODERS = {'1.2.840.10008.1.2.4.57': 'ljpeg_decode', '1.2.840.10008.1.2.4.70': 'ljpeg_decode', '1.2.840.10008.1.2.4.80': 'jpegls_decode', '1.2.840.10008.1.2.4.81': 'jpegls_decode', '1.2.840.10008.1.2.4.90': 'jpeg2k_decode', '1.2.840.10008.1.2.4.91': 'jpeg2k_decode'} module-attribute

DicomSource

Bases: ImageSource

ImageSource subclass for reading DICOM files using pydicom.

A folder (or the folder of a DICOMDIR) can hold several series: each series is an image, selected by its SeriesInstanceUID (image_uuid), its files the slices in order of position.

Source code in src/biomero_converter/DicomSource.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
class DicomSource(ImageSource):
    """
    ImageSource subclass for reading DICOM files using pydicom.

    A folder (or the folder of a DICOMDIR) can hold several series: each series is an image, selected by its
    SeriesInstanceUID (image_uuid), its files the slices in order of position.
    """

    def __init__(self, uri, metadata={}, image_uuid=None, **kwargs):
        """
        Initialize DicomSource.

        Args:
            uri (str): Path to a DICOM file, a folder of DICOM files or a DICOMDIR.
            metadata (dict): Optional metadata dictionary.
            image_uuid (str, optional): SeriesInstanceUID of the series to read (default: the first).
        """
        super().__init__(uri, metadata)
        series = self._find_series(uri)
        if not series:
            raise ValueError(f'No DICOM images found in {uri}')
        if image_uuid:
            if image_uuid not in series:
                raise ValueError(f'Series UID {image_uuid} not found in {uri}')
            self.series_uid = image_uuid
        else:
            self.series_uid = next(iter(series))
        self.nseries = len(series)
        self.filenames, nleft_out = series[self.series_uid]
        if nleft_out:
            logging.warning(f'DICOM series {self.series_uid}: using {len(self.filenames)} slices of one orientation, '
                            f'leaving out {nleft_out} (e.g. a reference image)')
        self.dicom = dcmread(self.filenames[0])

    @staticmethod
    def get_series(uri):
        """
        Find the series in a DICOM file, folder or DICOMDIR.

        Returns:
            dict: Per SeriesInstanceUID, its filenames in order of slice position.
        """
        return {series_uid: filenames for series_uid, (filenames, _) in DicomSource._find_series(uri).items()}

    @staticmethod
    def _find_series(uri):
        # per SeriesInstanceUID: its filenames in order of slice position, and the number of files left out
        if os.path.isfile(uri) and os.path.basename(uri).upper() != 'DICOMDIR':
            dicom = dcmread(uri, stop_before_pixels=True)
            return {str(dicom.get('SeriesInstanceUID', '')): ([uri], 0)}
        folder = uri if os.path.isdir(uri) else os.path.dirname(uri)
        groups = {}
        for root, _, filenames in os.walk(folder):
            for filename in filenames:
                path = os.path.join(root, filename)
                try:
                    dicom = dcmread(path, stop_before_pixels=True)
                except (InvalidDicomError, OSError):
                    continue
                if 'Rows' not in dicom:
                    # no image (e.g. DICOMDIR, report)
                    continue
                series_uid = str(dicom.get('SeriesInstanceUID', path))
                orientation = tuple(round(float(value), 3) for value in dicom.get('ImageOrientationPatient', []))
                groups.setdefault(series_uid, {}).setdefault((orientation, dicom.Rows, dicom.Columns), []).append(
                    (path, dicom))
        series = []
        for series_uid, series_groups in groups.items():
            # slices of one orientation and size: the largest group (e.g. not a reformat's reference image)
            group = max(series_groups.values(), key=len)
            nleft_out = sum(map(len, series_groups.values())) - len(group)
            group.sort(key=lambda item: DicomSource._get_slice_order(item[1]))
            first = group[0][1]
            order = (str(first.get('StudyInstanceUID', '')), int(first.get('SeriesNumber') or 0), group[0][0])
            series.append((order, series_uid, [path for path, _ in group], nleft_out))
        return {series_uid: (filenames, nleft_out) for _, series_uid, filenames, nleft_out in sorted(series)}

    @staticmethod
    def _get_slice_position(dicom):
        # position along the slice normal (the cross product of the image row and column directions)
        orientation = dicom.get('ImageOrientationPatient')
        position = dicom.get('ImagePositionPatient')
        if orientation is None or position is None or len(orientation) != 6:
            return None
        normal = np.cross(np.array(orientation[:3], dtype=float), np.array(orientation[3:], dtype=float))
        return float(np.dot(normal, np.array(position, dtype=float)))

    @staticmethod
    def _get_slice_order(dicom):
        position = DicomSource._get_slice_position(dicom)
        return (position if position is not None else 0, int(dicom.get('InstanceNumber') or 0))

    def init_metadata(self):
        metadata = dicom_to_dict(self.dicom)
        self.metadata = metadata
        pixel_array = self._read_pixels(self.dicom)
        shape = list(pixel_array.shape)
        self.is_rgb_type = (metadata.get('PhotometricInterpretation').lower() == 'rgb')
        dim_order = 'yx'
        nchannels = 1
        if self.is_rgb_type:
            # pydicom gives the samples last
            nchannels = shape[-1]
            dim_order = dim_order + 'c'
        self.dtype = pixel_array.dtype
        self.pixel_size = {dim:value for dim, value in zip('xy', metadata.get('PixelSpacing', (1, 1)))}
        if int(metadata.get('NumberOfFrames') or 1) > 1:
            # multi-frame: frames are time points if they increment by frame time (e.g. cine, ultrasound), else slices
            dim_order = ('t' if self._frames_are_time() else 'z') + dim_order
        nfiles = len(self.filenames)
        if nfiles > 1:
            # files are slices
            if 'z' in dim_order:
                shape[0] *= nfiles
            else:
                z_index = 1 if 't' in dim_order else 0
                dim_order = dim_order[:z_index] + 'z' + dim_order[z_index:]
                shape.insert(z_index, nfiles)
        if 'z' in dim_order:
            self.pixel_size['z'] = self._get_slice_spacing()
        self.shape = shape
        self.nchannels = nchannels
        self.dim_order = dim_order
        self.shapes = [self.shape]
        self.scales = [1]
        if 'ImagePositionPatient' in metadata:
            self.position = {dim: size for dim, size in zip('xyz', metadata['ImagePositionPatient'])}
        else:
            self.position = None
        self.acquisition_datetime = None
        for prefix in ['Acquisition', 'Series', 'Study']:
            date = metadata.get(prefix + 'Date')
            if date:
                # DICOM DA (YYYYMMDD) and TM (HH[MM[SS[.FFFFFF]]]) values
                time_value = metadata.get(prefix + 'Time')
                self.acquisition_datetime = datetime.combine(DA(date), TM(time_value) if time_value else time())
                break
        self.bits_per_pixel = self.metadata.get('BitsStored', self.dtype.itemsize * 8)

        name = self.metadata.get('SeriesDescription')
        if not name:
            name = self.metadata.get('StudyDescription')
        if not name:
            # the folder's name for a DICOMDIR
            is_dicomdir = os.path.basename(self.uri).upper() == 'DICOMDIR'
            name = get_filetitle(os.path.dirname(self.uri) if is_dicomdir else self.uri)
        if self.nseries > 1:
            name = f"{name}_{self.metadata.get('SeriesNumber', self.series_uid)}"
        self.name = name

        return self.metadata

    def _get_slice_spacing(self):
        # slice spacing from the slice positions, else as stated
        if len(self.filenames) > 1:
            positions = [self._get_slice_position(dcmread(filename, stop_before_pixels=True))
                         for filename in self.filenames]
            if None not in positions:
                steps = [step for step in np.abs(np.diff(positions)) if step > 0]
                if steps:
                    return float(np.median(steps))
        spacing = self.metadata.get('SpacingBetweenSlices')
        if spacing:
            return abs(spacing)
        return self.metadata.get('SliceThickness', 1)

    def _frames_are_time(self):
        frame_increment_pointer = self.dicom.get('FrameIncrementPointer')
        if frame_increment_pointer is None:
            return False
        tags = frame_increment_pointer if isinstance(frame_increment_pointer, MultiValue) else [frame_increment_pointer]
        return any(keyword_for_tag(tag) in ['FrameTime', 'FrameTimeVector'] for tag in tags)

    @staticmethod
    def _read_pixels(dicom):
        try:
            return dicom.pixel_array
        except RuntimeError:
            # no pydicom decoder plugin for this compression: decode with imagecodecs instead
            transfer_syntax = str(dicom.file_meta.TransferSyntaxUID)
            if transfer_syntax not in IMAGECODECS_DECODERS:
                raise
            import imagecodecs
            decode = getattr(imagecodecs, IMAGECODECS_DECODERS[transfer_syntax])
            nframes = int(dicom.get('NumberOfFrames') or 1)
            frames = [decode(frame) for frame in generate_frames(dicom.PixelData, number_of_frames=nframes)]
            data = np.stack(frames) if nframes > 1 else frames[0]
            if dicom.get('PixelRepresentation') == 1 and data.dtype.kind == 'u':
                # signed values: sign-extend the stored bits
                data = data.view(data.dtype.str.replace('u', 'i'))
                unused_bits = data.dtype.itemsize * 8 - int(dicom.get('BitsStored') or data.dtype.itemsize * 8)
                if unused_bits > 0:
                    data = (data << unused_bits) >> unused_bits
            return data

    def is_screen(self):
        # DICOM files are not multi-well screens
        return False

    def is_rgb(self):
        return self.is_rgb_type

    def get_name(self):
        return self.name

    def get_shape(self):
        return self.shape

    def get_shapes(self):
        return self.shapes

    def get_dtype(self):
        return self.dtype

    def get_scales(self):
        return self.scales

    def get_dim_order(self):
        return self.dim_order

    def get_channels(self):
        if self.is_rgb():
            labels = ['Red', 'Green', 'Blue']
            colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1)]
            return [{'label': label, 'color': color} for label, color in zip(labels, colors)]
        else:
            return [{'label': f'Channel {index}', 'color': [1, 1, 1, 1]} for index in range(self.nchannels)]

    def get_nchannels(self):
        return self.nchannels

    def get_pixel_size_um(self):
        return {dim: size * 1e3 for dim, size in self.pixel_size.items()}

    def get_position_um(self, well_id=None):
        if self.position:
            return {dim: size * 1e3 for dim, size in self.position.items()}
        else:
            return None

    def get_acquisition_datetime(self):
        return self.acquisition_datetime

    def get_significant_bits(self):
        return self.bits_per_pixel

    def get_acquisition_metadata(self):
        # all DICOM attributes as they are (patient details included), for mapping onto the imaging metadata model
        return self.metadata

    def get_source_metadata(self):
        # the attributes but the padding that fills out the file (zeros), meaningless once the pixels are converted
        return {key: value for key, value in self.metadata.items() if key != 'DataSetTrailingPadding'}

    def get_time_points(self):
        if 't' in self.dim_order:
            return list(range(self.shape[self.dim_order.index('t')]))
        return []

    def get_rows(self):
        return []

    def get_columns(self):
        return []

    def get_wells(self):
        return []

    def get_fields(self):
        return []

    def get_acquisitions(self):
        return []

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        # https://pydicom.github.io/pydicom/stable/auto_examples/image_processing/reslice.html#sphx-glr-auto-examples-image-processing-reslice-py
        if len(self.filenames) > 1:
            arrays = [self._read_pixels(dcmread(filename)) for filename in self.filenames]
            if int(self.metadata.get('NumberOfFrames') or 1) > 1 and self.dim_order[0] == 'z':
                data = np.concatenate(arrays)
            else:
                data = np.stack(arrays, axis=self.dim_order.index('z'))
        else:
            data = self._read_pixels(self.dicom)
        return redimension_data(data, self.dim_order, dim_order)

dicom = dcmread(self.filenames[0]) instance-attribute

nseries = len(series) instance-attribute

series_uid = image_uuid instance-attribute

__init__(uri, metadata={}, image_uuid=None, **kwargs)

Initialize DicomSource.

Parameters:

Name Type Description Default
uri str

Path to a DICOM file, a folder of DICOM files or a DICOMDIR.

required
metadata dict

Optional metadata dictionary.

{}
image_uuid str

SeriesInstanceUID of the series to read (default: the first).

None
Source code in src/biomero_converter/DicomSource.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, uri, metadata={}, image_uuid=None, **kwargs):
    """
    Initialize DicomSource.

    Args:
        uri (str): Path to a DICOM file, a folder of DICOM files or a DICOMDIR.
        metadata (dict): Optional metadata dictionary.
        image_uuid (str, optional): SeriesInstanceUID of the series to read (default: the first).
    """
    super().__init__(uri, metadata)
    series = self._find_series(uri)
    if not series:
        raise ValueError(f'No DICOM images found in {uri}')
    if image_uuid:
        if image_uuid not in series:
            raise ValueError(f'Series UID {image_uuid} not found in {uri}')
        self.series_uid = image_uuid
    else:
        self.series_uid = next(iter(series))
    self.nseries = len(series)
    self.filenames, nleft_out = series[self.series_uid]
    if nleft_out:
        logging.warning(f'DICOM series {self.series_uid}: using {len(self.filenames)} slices of one orientation, '
                        f'leaving out {nleft_out} (e.g. a reference image)')
    self.dicom = dcmread(self.filenames[0])

get_acquisition_datetime()

Source code in src/biomero_converter/DicomSource.py
269
270
def get_acquisition_datetime(self):
    return self.acquisition_datetime

get_acquisition_metadata()

Source code in src/biomero_converter/DicomSource.py
275
276
277
def get_acquisition_metadata(self):
    # all DICOM attributes as they are (patient details included), for mapping onto the imaging metadata model
    return self.metadata

get_acquisitions()

Source code in src/biomero_converter/DicomSource.py
300
301
def get_acquisitions(self):
    return []

get_channels()

Source code in src/biomero_converter/DicomSource.py
249
250
251
252
253
254
255
def get_channels(self):
    if self.is_rgb():
        labels = ['Red', 'Green', 'Blue']
        colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1)]
        return [{'label': label, 'color': color} for label, color in zip(labels, colors)]
    else:
        return [{'label': f'Channel {index}', 'color': [1, 1, 1, 1]} for index in range(self.nchannels)]

get_columns()

Source code in src/biomero_converter/DicomSource.py
291
292
def get_columns(self):
    return []

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Source code in src/biomero_converter/DicomSource.py
303
304
305
306
307
308
309
310
311
312
313
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    # https://pydicom.github.io/pydicom/stable/auto_examples/image_processing/reslice.html#sphx-glr-auto-examples-image-processing-reslice-py
    if len(self.filenames) > 1:
        arrays = [self._read_pixels(dcmread(filename)) for filename in self.filenames]
        if int(self.metadata.get('NumberOfFrames') or 1) > 1 and self.dim_order[0] == 'z':
            data = np.concatenate(arrays)
        else:
            data = np.stack(arrays, axis=self.dim_order.index('z'))
    else:
        data = self._read_pixels(self.dicom)
    return redimension_data(data, self.dim_order, dim_order)

get_dim_order()

Source code in src/biomero_converter/DicomSource.py
246
247
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/DicomSource.py
240
241
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/DicomSource.py
297
298
def get_fields(self):
    return []

get_name()

Source code in src/biomero_converter/DicomSource.py
231
232
def get_name(self):
    return self.name

get_nchannels()

Source code in src/biomero_converter/DicomSource.py
257
258
def get_nchannels(self):
    return self.nchannels

get_pixel_size_um()

Source code in src/biomero_converter/DicomSource.py
260
261
def get_pixel_size_um(self):
    return {dim: size * 1e3 for dim, size in self.pixel_size.items()}

get_position_um(well_id=None)

Source code in src/biomero_converter/DicomSource.py
263
264
265
266
267
def get_position_um(self, well_id=None):
    if self.position:
        return {dim: size * 1e3 for dim, size in self.position.items()}
    else:
        return None

get_rows()

Source code in src/biomero_converter/DicomSource.py
288
289
def get_rows(self):
    return []

get_scales()

Source code in src/biomero_converter/DicomSource.py
243
244
def get_scales(self):
    return self.scales

get_series(uri) staticmethod

Find the series in a DICOM file, folder or DICOMDIR.

Returns:

Name Type Description
dict

Per SeriesInstanceUID, its filenames in order of slice position.

Source code in src/biomero_converter/DicomSource.py
63
64
65
66
67
68
69
70
71
@staticmethod
def get_series(uri):
    """
    Find the series in a DICOM file, folder or DICOMDIR.

    Returns:
        dict: Per SeriesInstanceUID, its filenames in order of slice position.
    """
    return {series_uid: filenames for series_uid, (filenames, _) in DicomSource._find_series(uri).items()}

get_shape()

Source code in src/biomero_converter/DicomSource.py
234
235
def get_shape(self):
    return self.shape

get_shapes()

Source code in src/biomero_converter/DicomSource.py
237
238
def get_shapes(self):
    return self.shapes

get_significant_bits()

Source code in src/biomero_converter/DicomSource.py
272
273
def get_significant_bits(self):
    return self.bits_per_pixel

get_source_metadata()

Source code in src/biomero_converter/DicomSource.py
279
280
281
def get_source_metadata(self):
    # the attributes but the padding that fills out the file (zeros), meaningless once the pixels are converted
    return {key: value for key, value in self.metadata.items() if key != 'DataSetTrailingPadding'}

get_time_points()

Source code in src/biomero_converter/DicomSource.py
283
284
285
286
def get_time_points(self):
    if 't' in self.dim_order:
        return list(range(self.shape[self.dim_order.index('t')]))
    return []

get_wells()

Source code in src/biomero_converter/DicomSource.py
294
295
def get_wells(self):
    return []

init_metadata()

Source code in src/biomero_converter/DicomSource.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def init_metadata(self):
    metadata = dicom_to_dict(self.dicom)
    self.metadata = metadata
    pixel_array = self._read_pixels(self.dicom)
    shape = list(pixel_array.shape)
    self.is_rgb_type = (metadata.get('PhotometricInterpretation').lower() == 'rgb')
    dim_order = 'yx'
    nchannels = 1
    if self.is_rgb_type:
        # pydicom gives the samples last
        nchannels = shape[-1]
        dim_order = dim_order + 'c'
    self.dtype = pixel_array.dtype
    self.pixel_size = {dim:value for dim, value in zip('xy', metadata.get('PixelSpacing', (1, 1)))}
    if int(metadata.get('NumberOfFrames') or 1) > 1:
        # multi-frame: frames are time points if they increment by frame time (e.g. cine, ultrasound), else slices
        dim_order = ('t' if self._frames_are_time() else 'z') + dim_order
    nfiles = len(self.filenames)
    if nfiles > 1:
        # files are slices
        if 'z' in dim_order:
            shape[0] *= nfiles
        else:
            z_index = 1 if 't' in dim_order else 0
            dim_order = dim_order[:z_index] + 'z' + dim_order[z_index:]
            shape.insert(z_index, nfiles)
    if 'z' in dim_order:
        self.pixel_size['z'] = self._get_slice_spacing()
    self.shape = shape
    self.nchannels = nchannels
    self.dim_order = dim_order
    self.shapes = [self.shape]
    self.scales = [1]
    if 'ImagePositionPatient' in metadata:
        self.position = {dim: size for dim, size in zip('xyz', metadata['ImagePositionPatient'])}
    else:
        self.position = None
    self.acquisition_datetime = None
    for prefix in ['Acquisition', 'Series', 'Study']:
        date = metadata.get(prefix + 'Date')
        if date:
            # DICOM DA (YYYYMMDD) and TM (HH[MM[SS[.FFFFFF]]]) values
            time_value = metadata.get(prefix + 'Time')
            self.acquisition_datetime = datetime.combine(DA(date), TM(time_value) if time_value else time())
            break
    self.bits_per_pixel = self.metadata.get('BitsStored', self.dtype.itemsize * 8)

    name = self.metadata.get('SeriesDescription')
    if not name:
        name = self.metadata.get('StudyDescription')
    if not name:
        # the folder's name for a DICOMDIR
        is_dicomdir = os.path.basename(self.uri).upper() == 'DICOMDIR'
        name = get_filetitle(os.path.dirname(self.uri) if is_dicomdir else self.uri)
    if self.nseries > 1:
        name = f"{name}_{self.metadata.get('SeriesNumber', self.series_uid)}"
    self.name = name

    return self.metadata

is_rgb()

Source code in src/biomero_converter/DicomSource.py
228
229
def is_rgb(self):
    return self.is_rgb_type

is_screen()

Source code in src/biomero_converter/DicomSource.py
224
225
226
def is_screen(self):
    # DICOM files are not multi-well screens
    return False

GenericSource

GenericSource

Bases: ImageSource

Source code in src/biomero_converter/GenericSource.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class GenericSource(ImageSource):
    def __init__(self, uri, **kwargs):
        super().__init__(uri, **kwargs)
        self.format = os.path.splitext(uri)[1].lower().lstrip('.')
        self.metadata = None
        im = None
        try:
            im = iio.imopen(uri, 'r')
            self.metadata = im.metadata()
            self.data_func = im.read
        except OSError as error:
            error = str(error)
            match = re.search(r"plugin='\w+'", error)
            if match:
                parts = match.group().split('=')
                if len(parts) == 2:
                    self.format = parts[1].strip("'").lower()
        except Exception:
            if im:
                if hasattr(im, 'legacy_get_reader'):
                    reader = im.legacy_get_reader()
                    self.format = reader.format.name.lower()
                    self.metadata = reader.get_meta_data()
                    self.data_func = reader.get_data

    def init_metadata(self):
        return self.metadata

    def get_data(self, **kwargs):
        return self.data_func(**kwargs)

data_func = im.read instance-attribute

format = os.path.splitext(uri)[1].lower().lstrip('.') instance-attribute

metadata = im.metadata() instance-attribute

__init__(uri, **kwargs)

Source code in src/biomero_converter/GenericSource.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def __init__(self, uri, **kwargs):
    super().__init__(uri, **kwargs)
    self.format = os.path.splitext(uri)[1].lower().lstrip('.')
    self.metadata = None
    im = None
    try:
        im = iio.imopen(uri, 'r')
        self.metadata = im.metadata()
        self.data_func = im.read
    except OSError as error:
        error = str(error)
        match = re.search(r"plugin='\w+'", error)
        if match:
            parts = match.group().split('=')
            if len(parts) == 2:
                self.format = parts[1].strip("'").lower()
    except Exception:
        if im:
            if hasattr(im, 'legacy_get_reader'):
                reader = im.legacy_get_reader()
                self.format = reader.format.name.lower()
                self.metadata = reader.get_meta_data()
                self.data_func = reader.get_data

get_data(**kwargs)

Source code in src/biomero_converter/GenericSource.py
36
37
def get_data(self, **kwargs):
    return self.data_func(**kwargs)

init_metadata()

Source code in src/biomero_converter/GenericSource.py
33
34
def init_metadata(self):
    return self.metadata

ISyntaxSource

OME_DIR = 'OME' module-attribute

OME_FILE = 'METADATA.ome.xml' module-attribute

PYRAMID_DOWNSCALE = 2 module-attribute

PYRAMID_LEVELS = 6 module-attribute

RETRY_ATTEMPTS = 3 module-attribute

TIFF_COMPRESSION = 'LZW' module-attribute

TILE_SIZE = 1024 module-attribute

VERSION = get_version() module-attribute

ZARR_CHUNK_SIZE = TILE_SIZE module-attribute

ZARR_SHARD_MULTIPLIER = 10 module-attribute

ISyntaxSource

Bases: ImageSource

Loads image and metadata from ISyntax format files.

Source code in src/biomero_converter/ISyntaxSource.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
class ISyntaxSource(ImageSource):
    """
    Loads image and metadata from ISyntax format files.
    """
    def init_metadata(self):
        # read XML metadata header
        data = b''
        block_size = 1024 * 1024
        end_char = b'\x04'   # EOT character
        with open(self.uri, mode='rb') as file:
            done = False
            while not done:
                data_block = file.read(block_size)
                if end_char in data_block:
                    index = data_block.index(end_char)
                    data_block = data_block[:index]
                    done = True
                data += data_block

        self.metadata = xml_content_to_dict(ElementTree.XML(data.decode()))
        if 'DPUfsImport' in self.metadata:
            self.metadata = self.metadata['DPUfsImport']

        image = None
        image_type = ''
        for image0 in self.metadata.get('PIM_DP_SCANNED_IMAGES', []):
            image = image0.get('DPScannedImage', {})
            image_type = image.get('PIM_DP_IMAGE_TYPE').lower()
            if image_type in ['wsi']:
                break

        if image is not None:
            self.image_type = image_type
            nbits = image.get('UFS_IMAGE_BLOCK_HEADER_TEMPLATES', [{}])[0].get('UFSImageBlockHeaderTemplate', {}).get('DICOM_BITS_STORED', 16)
        else:
            self.image_type = ''
            nbits = 16

        self.is_plate = 'screen' in self.image_type or 'plate' in self.image_type or 'wells' in self.image_type

        self.isyntax = ISyntax.open(self.uri)
        self.dimensions = self.isyntax.level_dimensions
        self.widths = [width for width, height in self.isyntax.level_dimensions]
        self.heights = [height for width, height in self.isyntax.level_dimensions]
        self.scales = [1 / downsample for downsample in self.isyntax.level_downsamples]

        # original color channels get converted in pyisyntax package to 8-bit RGBA; convert to RGB
        nbits = 8
        self.nchannels = 3
        self.shapes = [(height, width, self.nchannels) for (width, height) in self.dimensions]
        self.shape = self.shapes[0]
        self.dim_order = 'yxc'
        self.is_rgb_channels = True
        self.dtype = get_bits_type(nbits)
        self.pixel_size = {'x': self.isyntax.mpp_x, 'y': self.isyntax.mpp_y}
        self.bits_per_pixel = nbits

        self.name = get_filetitle(self.uri)
        self.acquisition_datetime = datetime.strptime(str(self.metadata.get('DICOM_ACQUISITION_DATETIME')),'%Y%m%d%H%M%S.%f')
        return self.metadata

    def is_screen(self):
        return self.is_plate

    def get_shape(self):
        return self.shape

    def get_shapes(self):
        return self.shapes

    def get_scales(self):
        return self.scales

    def read_array(self, x, y, width, height, level=0):
        rgba = self.isyntax.read_region(x, y, width, height, level)
        alpha = np.atleast_3d(rgba[..., 3] / np.float32(255))
        rgb = (rgba[..., :3] * alpha).astype(np.uint8)
        return rgb

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        data = self.read_array(0, 0, self.widths[level], self.heights[level], level=level)
        return redimension_data(data, self.dim_order, dim_order)

    def get_data_as_dask(self, dim_order, level=0, **kwargs):
        dask.config.set(scheduler='single-threaded')

        def get_lazy_tile(x, y, width, height, level=0):
            lazy_array = dask.delayed(self.read_array)(x, y, width, height, level)
            return da.from_delayed(lazy_array, shape=(height, width, self.nchannels), dtype=self.dtype)

        y_chunks, x_chunks = da.core.normalize_chunks(TILE_SIZE, self.shapes[level][:2], dtype=self.dtype)
        y_pos = np.cumsum([0] + list(y_chunks)[:-1])
        x_pos = np.cumsum([0] + list(x_chunks)[:-1])
        data = da.concatenate(
            [da.concatenate(
                [get_lazy_tile(x, y, width, height, level=level)
                 for x, width in zip(x_pos, x_chunks)], axis=1)
             for y, height in zip(y_pos, y_chunks)], axis=0)
        return redimension_data(data, self.dim_order, dim_order)

    def get_data_as_generator(self, dim_order, **kwargs):
        def data_generator(scale=1):
            level, rescale = get_level_from_scale(self.scales, scale)
            read_size = int(TILE_SIZE / rescale)
            for y in range(0, self.heights[level], read_size):
                for x in range(0, self.widths[level], read_size):
                    data = self.read_array(x, y, read_size, read_size, level)
                    if rescale != 1:
                        shape = np.multiply(data.shape[:2], rescale).astype(int)
                        data = sk_transform.resize(data, shape, preserve_range=True).astype(data.dtype)
                    yield redimension_data(data, self.dim_order, dim_order)
        return data_generator

    def get_name(self):
        return self.name

    def get_dim_order(self):
        return self.dim_order

    def get_pixel_size_um(self):
        return self.pixel_size

    def get_dtype(self):
        return self.dtype

    def get_position_um(self, well_id=None):
        return {'x': self.isyntax.offset_x, 'y': self.isyntax.offset_y}

    def get_channels(self):
        # Mirax is RGB, return NGFF-style channel metadata
        return [
            {"name": "Red", "color": [1, 0, 0, 1]},
            {"name": "Green", "color": [0, 1, 0, 1]},
            {"name": "Blue", "color": [0, 0, 1, 1]},
            #{"name": "Alpha", "color": [1, 1, 1, 1]}
        ]

    def get_nchannels(self):
        return self.nchannels

    def is_rgb(self):
        return self.is_rgb_channels

    def get_rows(self):
        return []

    def get_columns(self):
        return []

    def get_wells(self):
        return []

    def get_time_points(self):
        return []

    def get_fields(self):
        return []

    def get_acquisitions(self):
        return []

    def get_acquisition_datetime(self):
        return self.acquisition_datetime

    def get_significant_bits(self):
        return self.bits_per_pixel

    def close(self):
        self.isyntax.close()
        dask.config.set(scheduler='threads')

close()

Source code in src/biomero_converter/ISyntaxSource.py
184
185
186
def close(self):
    self.isyntax.close()
    dask.config.set(scheduler='threads')

get_acquisition_datetime()

Source code in src/biomero_converter/ISyntaxSource.py
178
179
def get_acquisition_datetime(self):
    return self.acquisition_datetime

get_acquisitions()

Source code in src/biomero_converter/ISyntaxSource.py
175
176
def get_acquisitions(self):
    return []

get_channels()

Source code in src/biomero_converter/ISyntaxSource.py
145
146
147
148
149
150
151
152
def get_channels(self):
    # Mirax is RGB, return NGFF-style channel metadata
    return [
        {"name": "Red", "color": [1, 0, 0, 1]},
        {"name": "Green", "color": [0, 1, 0, 1]},
        {"name": "Blue", "color": [0, 0, 1, 1]},
        #{"name": "Alpha", "color": [1, 1, 1, 1]}
    ]

get_columns()

Source code in src/biomero_converter/ISyntaxSource.py
163
164
def get_columns(self):
    return []

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Source code in src/biomero_converter/ISyntaxSource.py
96
97
98
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    data = self.read_array(0, 0, self.widths[level], self.heights[level], level=level)
    return redimension_data(data, self.dim_order, dim_order)

get_data_as_dask(dim_order, level=0, **kwargs)

Source code in src/biomero_converter/ISyntaxSource.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def get_data_as_dask(self, dim_order, level=0, **kwargs):
    dask.config.set(scheduler='single-threaded')

    def get_lazy_tile(x, y, width, height, level=0):
        lazy_array = dask.delayed(self.read_array)(x, y, width, height, level)
        return da.from_delayed(lazy_array, shape=(height, width, self.nchannels), dtype=self.dtype)

    y_chunks, x_chunks = da.core.normalize_chunks(TILE_SIZE, self.shapes[level][:2], dtype=self.dtype)
    y_pos = np.cumsum([0] + list(y_chunks)[:-1])
    x_pos = np.cumsum([0] + list(x_chunks)[:-1])
    data = da.concatenate(
        [da.concatenate(
            [get_lazy_tile(x, y, width, height, level=level)
             for x, width in zip(x_pos, x_chunks)], axis=1)
         for y, height in zip(y_pos, y_chunks)], axis=0)
    return redimension_data(data, self.dim_order, dim_order)

get_data_as_generator(dim_order, **kwargs)

Source code in src/biomero_converter/ISyntaxSource.py
117
118
119
120
121
122
123
124
125
126
127
128
def get_data_as_generator(self, dim_order, **kwargs):
    def data_generator(scale=1):
        level, rescale = get_level_from_scale(self.scales, scale)
        read_size = int(TILE_SIZE / rescale)
        for y in range(0, self.heights[level], read_size):
            for x in range(0, self.widths[level], read_size):
                data = self.read_array(x, y, read_size, read_size, level)
                if rescale != 1:
                    shape = np.multiply(data.shape[:2], rescale).astype(int)
                    data = sk_transform.resize(data, shape, preserve_range=True).astype(data.dtype)
                yield redimension_data(data, self.dim_order, dim_order)
    return data_generator

get_dim_order()

Source code in src/biomero_converter/ISyntaxSource.py
133
134
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/ISyntaxSource.py
139
140
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/ISyntaxSource.py
172
173
def get_fields(self):
    return []

get_name()

Source code in src/biomero_converter/ISyntaxSource.py
130
131
def get_name(self):
    return self.name

get_nchannels()

Source code in src/biomero_converter/ISyntaxSource.py
154
155
def get_nchannels(self):
    return self.nchannels

get_pixel_size_um()

Source code in src/biomero_converter/ISyntaxSource.py
136
137
def get_pixel_size_um(self):
    return self.pixel_size

get_position_um(well_id=None)

Source code in src/biomero_converter/ISyntaxSource.py
142
143
def get_position_um(self, well_id=None):
    return {'x': self.isyntax.offset_x, 'y': self.isyntax.offset_y}

get_rows()

Source code in src/biomero_converter/ISyntaxSource.py
160
161
def get_rows(self):
    return []

get_scales()

Source code in src/biomero_converter/ISyntaxSource.py
87
88
def get_scales(self):
    return self.scales

get_shape()

Source code in src/biomero_converter/ISyntaxSource.py
81
82
def get_shape(self):
    return self.shape

get_shapes()

Source code in src/biomero_converter/ISyntaxSource.py
84
85
def get_shapes(self):
    return self.shapes

get_significant_bits()

Source code in src/biomero_converter/ISyntaxSource.py
181
182
def get_significant_bits(self):
    return self.bits_per_pixel

get_time_points()

Source code in src/biomero_converter/ISyntaxSource.py
169
170
def get_time_points(self):
    return []

get_wells()

Source code in src/biomero_converter/ISyntaxSource.py
166
167
def get_wells(self):
    return []

init_metadata()

Source code in src/biomero_converter/ISyntaxSource.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def init_metadata(self):
    # read XML metadata header
    data = b''
    block_size = 1024 * 1024
    end_char = b'\x04'   # EOT character
    with open(self.uri, mode='rb') as file:
        done = False
        while not done:
            data_block = file.read(block_size)
            if end_char in data_block:
                index = data_block.index(end_char)
                data_block = data_block[:index]
                done = True
            data += data_block

    self.metadata = xml_content_to_dict(ElementTree.XML(data.decode()))
    if 'DPUfsImport' in self.metadata:
        self.metadata = self.metadata['DPUfsImport']

    image = None
    image_type = ''
    for image0 in self.metadata.get('PIM_DP_SCANNED_IMAGES', []):
        image = image0.get('DPScannedImage', {})
        image_type = image.get('PIM_DP_IMAGE_TYPE').lower()
        if image_type in ['wsi']:
            break

    if image is not None:
        self.image_type = image_type
        nbits = image.get('UFS_IMAGE_BLOCK_HEADER_TEMPLATES', [{}])[0].get('UFSImageBlockHeaderTemplate', {}).get('DICOM_BITS_STORED', 16)
    else:
        self.image_type = ''
        nbits = 16

    self.is_plate = 'screen' in self.image_type or 'plate' in self.image_type or 'wells' in self.image_type

    self.isyntax = ISyntax.open(self.uri)
    self.dimensions = self.isyntax.level_dimensions
    self.widths = [width for width, height in self.isyntax.level_dimensions]
    self.heights = [height for width, height in self.isyntax.level_dimensions]
    self.scales = [1 / downsample for downsample in self.isyntax.level_downsamples]

    # original color channels get converted in pyisyntax package to 8-bit RGBA; convert to RGB
    nbits = 8
    self.nchannels = 3
    self.shapes = [(height, width, self.nchannels) for (width, height) in self.dimensions]
    self.shape = self.shapes[0]
    self.dim_order = 'yxc'
    self.is_rgb_channels = True
    self.dtype = get_bits_type(nbits)
    self.pixel_size = {'x': self.isyntax.mpp_x, 'y': self.isyntax.mpp_y}
    self.bits_per_pixel = nbits

    self.name = get_filetitle(self.uri)
    self.acquisition_datetime = datetime.strptime(str(self.metadata.get('DICOM_ACQUISITION_DATETIME')),'%Y%m%d%H%M%S.%f')
    return self.metadata

is_rgb()

Source code in src/biomero_converter/ISyntaxSource.py
157
158
def is_rgb(self):
    return self.is_rgb_channels

is_screen()

Source code in src/biomero_converter/ISyntaxSource.py
78
79
def is_screen(self):
    return self.is_plate

read_array(x, y, width, height, level=0)

Source code in src/biomero_converter/ISyntaxSource.py
90
91
92
93
94
def read_array(self, x, y, width, height, level=0):
    rgba = self.isyntax.read_region(x, y, width, height, level)
    alpha = np.atleast_3d(rgba[..., 3] / np.float32(255))
    rgb = (rgba[..., :3] * alpha).astype(np.uint8)
    return rgb

get_version()

Get the version from the GitHub release tag.

Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image), otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag), otherwise the version of the installed package (taken from the tag when it was built).

Returns:

Name Type Description
str

Version string, or 'unknown' if it cannot be determined.

Source code in src/biomero_converter/version.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def get_version():
    """
    Get the version from the GitHub release tag.

    Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image),
    otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag),
    otherwise the version of the installed package (taken from the tag when it was built).

    Returns:
        str: Version string, or 'unknown' if it cannot be determined.
    """
    version = os.environ.get('BIOMERO_CONVERTER_VERSION')
    if version:
        return version
    # only this repo's own checkout: an installed package may sit inside another git repo
    if os.path.exists(os.path.join(REPO_DIR, '.git')):
        try:
            return subprocess.run(['git', 'describe', '--tags', '--always'], cwd=REPO_DIR,
                                  capture_output=True, text=True, check=True, timeout=10).stdout.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    try:
        return 'v' + package_version('biomero-converter')
    except PackageNotFoundError:
        return 'unknown'

ImageDbSource

ImageDbSource

Bases: ImageSource

Loads image and metadata from a database source for high-content screening.

Source code in src/biomero_converter/ImageDbSource.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
class ImageDbSource(ImageSource):
    """
    Loads image and metadata from a database source for high-content screening.
    """
    def __init__(self, uri, metadata={}):
        super().__init__(uri, metadata)
        self.db = DbReader(self.uri)
        self.data = None
        self.data_well_id = None
        self.data_level = None
        self.dim_order = 'tczyx'

    def init_metadata(self):
        self._get_time_series_info()
        self._get_experiment_metadata()
        self._get_well_info()
        self._get_image_info()
        self._get_sizes()
        return self.metadata

    def get_shape(self):
        return self.shape

    def get_shapes(self):
        return self.shapes

    def get_scales(self):
        return self.scales

    def _get_time_series_info(self):
        """
        Loads time series and image file info into metadata.
        """
        time_series_ids = sorted(self.db.fetch_all('SELECT DISTINCT TimeSeriesElementId FROM SourceImageBase', return_dicts=False))
        self.time_points = time_series_ids

        level_ids = sorted(self.db.fetch_all('SELECT DISTINCT level FROM SourceImageBase', return_dicts=False))
        self.levels = level_ids

        image_files = {time_series_id: os.path.join(os.path.dirname(self.uri), f'images-{time_series_id}.db')
                       for time_series_id in time_series_ids}
        self.image_files = image_files

    def _get_experiment_metadata(self):
        """
        Loads experiment metadata and acquisition info into metadata.
        """
        creation_info = self.db.fetch_all('SELECT * FROM ExperimentBase')[0]
        creation_info['DateCreated'] = convert_dotnet_ticks_to_datetime(creation_info['DateCreated'])
        self.metadata.update(creation_info)

        acquisitions = self.db.fetch_all('SELECT Name, Description, DateCreated, DateModified FROM AcquisitionExp')
        for acquisition in acquisitions:
            acquisition['DateCreated'] = convert_dotnet_ticks_to_datetime(acquisition['DateCreated'])
            acquisition['DateModified'] = convert_dotnet_ticks_to_datetime(acquisition['DateModified'])
        self.acquisitions = acquisitions

    def _get_well_info(self):
        """
        Loads well and channel information into metadata.
        """
        info = self.db.fetch_all('''
            SELECT *
            FROM AcquisitionExp, AutomaticZonesParametersExp
        ''')[0]

        # Filter multiple duplicate channel entries
        channel_infos = self.db.fetch_all('''
            SELECT DISTINCT *
            FROM ImagechannelExp
            ORDER BY ChannelNumber
        ''')
        self.channels = channel_infos
        self.nchannels = len(channel_infos)

        wells = self.db.fetch_all('SELECT DISTINCT Name FROM Well')
        zone_names = [well['Name'] for well in wells]
        rows = set()
        cols = set()
        for zone_name in zone_names:
            row, col = split_well_name(zone_name)
            rows.add(row)
            cols.add(col)
        self.rows = sorted(list(rows))
        self.columns = sorted(list(cols), key=lambda x: int(x))
        nfields = info['SitesX'] * info['SitesY'] * info.get('SitesZ', 1)
        self.fields = list(range(nfields))
        self.well_info = info
        self.metadata['well_info'] = info

        image_wells = self.db.fetch_all('SELECT Name, ZoneIndex, CoordX, CoordY FROM Well WHERE HasImages = 1')
        self.wells = dict(sorted({well['Name']: well for well in image_wells}.items(),
                                             key=lambda x: split_well_name(x[0], col_as_int=True)))
        self.metadata['wells'] = self.wells
        self.pixel_size = info.get('PixelSizeUm', 1)

        # TODO: Dump all extra metadata in here
        self.acquisition_metadata = {
            'manufacturer': info['DeviceManufacturer'],
            'model': info['DeviceModel'],
            'name': info['DeviceName'],
            'serial_number': info['DeviceSerialNumber'],
            'magnification': info['Objective'],
            'n_a': info['NumericalAperture']
        }

    def _get_image_info(self):
        """
        Loads image bit depth and dtype info into metadata.
        """
        bits_per_pixel = self.db.fetch_all('SELECT DISTINCT BitsPerPixel FROM SourceImageBase', return_dicts=False)[0]
        self.bits_per_pixel = bits_per_pixel
        self.dtype = get_bits_type(bits_per_pixel)

    def _get_sizes(self):
        """
        Calculates and stores image shape and estimated data size.
        """
        shapes = []
        scales = []
        widths = []
        heights = []
        width0, height0 = self.well_info['SensorSizeXPixels'], self.well_info['SensorSizeYPixels']
        sizex0, sizey0 = None, None
        # Iterate through levels to get level size factor (SourceImageBase contains field-composite images)
        for level in self.levels:
            level_info = self.db.fetch_all(
                'SELECT MAX(CoordX + SizeX) as width, MAX(CoordY + SizeY) as height FROM SourceImageBase WHERE level = ?',
                [level])
            sizex, sizey = level_info[0]['width'], level_info[0]['height']
            if level == 0:
                sizex0, sizey0 = sizex, sizey
            width, height = width0 * sizex // sizex0, height0 * sizey // sizey0
            widths.append(width)
            heights.append(height)
            shape = len(self.time_points), self.nchannels, 1, height, width
            scale = np.mean([width / widths[0], height / heights[0]])
            shapes.append(shape)
            scales.append(scale)
        self.widths = widths
        self.heights = heights
        self.shape = shapes[0]
        self.shapes = shapes
        self.scales = scales

    def _read_well_info(self, well_id, channel=None, time_point=None, level=0):
        """
        Reads image info for a specific well, optionally filtered by channel and time point.

        Args:
            well_id (str): Well identifier.
            channel (int, optional): Channel ID.
            time_point (int, optional): Time point ID.
            level (int, optional): Image level index.

        Returns:
            list: Well image info dictionaries.
        """
        well_id = strip_leading_zeros(well_id)
        well_ids = self.wells

        if well_id not in well_ids:
            raise ValueError(f'Invalid Well: {well_id}. Available values: {well_ids}')

        zone_index = well_ids[well_id]['ZoneIndex']
        well_info = self.db.fetch_all('''
            SELECT *
            FROM SourceImageBase
            WHERE ZoneIndex = ? AND level = ?
            ORDER BY CoordX ASC, CoordY ASC
        ''', [zone_index, level])

        if channel is not None:
             well_info = [info for info in well_info if info['ChannelId'] == channel]
        if time_point is not None:
             well_info = [info for info in well_info if info['TimeSeriesElementId'] == time_point]
        if not well_info:
            raise ValueError(f'No data found for well {well_id}')
        return well_info

    def _assemble_image_data(self, well_info):
        """
        Assembles image data array using well info.

        Args:
            well_info (list): List of well image info dicts.
        """
        well_info = np.asarray(well_info)
        xmax = np.max([info['CoordX'] + info['SizeX'] for info in well_info])
        ymax = np.max([info['CoordY'] + info['SizeY'] for info in well_info])
        zmax = np.max([info.get('CoordZ', 0) + info.get('SizeZ', 1) for info in well_info])
        nc = len(set([info['ChannelId'] for info in well_info]))
        nt = len(set([info['TimeSeriesElementId'] for info in well_info]))
        data = np.zeros((nt, nc, zmax, ymax, xmax), dtype=self.dtype)

        for timei, time_id in enumerate(self.time_points):
            image_file = self.image_files[time_id]
            with open(image_file, 'rb') as fid:
                for info in well_info:
                    if info['TimeSeriesElementId'] == time_id:
                        fid.seek(info['ImageIndex'])
                        coordx, coordy, coordz = info['CoordX'], info['CoordY'], info.get('CoordZ', 0)
                        sizex, sizey, sizez = info['SizeX'], info['SizeY'], info.get('SizeZ', 1)
                        channeli = info['ChannelId']
                        tile = np.fromfile(fid, dtype=self.dtype, count=sizez * sizey * sizex)
                        data[timei, channeli, coordz:coordz + sizez, coordy:coordy + sizey, coordx:coordx + sizex] = tile.reshape((sizez, sizey, sizex))

        self.data = data

    def _extract_site(self, site_id=None):
        """
        Extracts image data for a specific site or all sites.

        Args:
            site_id (int, optional): Site index. If None, returns all data.

        Returns:
            ndarray or list: Image data for the site(s).
        """
        well_info = self.well_info
        sitesx = well_info['SitesX']
        sitesy = well_info['SitesY']
        sitesz = well_info.get('SitesZ', 1)
        nfields = len(self.fields)
        sizex = well_info['SensorSizeXPixels']
        sizey = well_info['SensorSizeYPixels']
        sizez = well_info.get('SensorSizeZPixels', 1)

        if site_id is None:
            # Return full image data
            return self.data

        site_id = int(site_id)
        if site_id < 0:
            # Return list of all fields
            data = []
            for zi in range(sitesz):
                for yi in range(sitesy):
                    for xi in range(sitesx):
                        startx = xi * sizex
                        starty = yi * sizey
                        startz = zi * sizez
                        data.append(self.data[..., startz:startz + sizez, starty:starty + sizey, startx:startx + sizex])
            return data
        elif 0 <= site_id < nfields:
            # Return specific site
            xi = site_id % sitesx
            yi = (site_id // sitesx) % sitesy
            zi = site_id // sitesx // sitesy
            startx = xi * sizex
            starty = yi * sizey
            startz = zi * sizez
            return self.data[..., startz:startz + sizez, starty:starty + sizey, startx:startx + sizex]
        else:
            raise ValueError(f'Invalid site: {site_id}')

    def is_screen(self):
        return len(self.wells) > 0

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        if not (well_id == self.data_well_id and level == self.data_level):
            self._assemble_image_data(self._read_well_info(well_id, level=level))
            self.data_well_id = well_id
            self.data_level = level
        return redimension_data(self._extract_site(field_id), self.dim_order, dim_order)

    def get_name(self):
        name = self.metadata.get('Name')
        if not name:
            name = splitall(os.path.splitext(self.uri)[0])[-2]
        return name

    def get_rows(self):
        return self.rows

    def get_columns(self):
        return self.columns

    def get_wells(self):
        return list(self.wells)

    def get_time_points(self):
        return self.time_points

    def get_fields(self):
        return self.fields

    def get_dim_order(self):
        return self.dim_order

    def get_dtype(self):
        return self.dtype

    def get_pixel_size_um(self):
        return {'x': self.pixel_size, 'y': self.pixel_size}

    def get_position_um(self, well_id=None, level=0):
        well = self.wells[well_id]
        x = well.get('CoordX', 0) * self.widths[level] * self.pixel_size
        y = well.get('CoordY', 0) * self.heights[level] * self.pixel_size
        return {'x': x, 'y': y}

    def get_channels(self):
        channels = []
        for channel0 in self.channels:
            channel = {}
            if 'Dye' in channel0 and channel0['Dye']:
                channel['label'] = channel0['Dye']
            if 'Color' in channel0:
                channel['color'] = hexrgb_to_rgba(channel0['Color'].lstrip('#'))
            emission = channel0.get('Emission')
            if emission:
                channel['emission_wavelength'] = emission
                channel['emission_wavelength_unit'] = 'nm'
            excitation = channel0.get('Excitation')
            if excitation:
                channel['excitation_wavelength'] = excitation
                channel['excitation_wavelength_unit'] = 'nm'
            channels.append(channel)
        return channels

    def get_nchannels(self):
        return max(self.nchannels, 1)

    def is_rgb(self):
        return False

    def get_acquisitions(self):
        acquisitions = []
        for index, acq in enumerate(self.acquisitions):
            acquisitions.append({
                'id': index,
                'name': acq['Name'],
                'description': acq['Description'],
                'date_created': acq['DateCreated'].isoformat(),
                'date_modified': acq['DateModified'].isoformat()
            })
        return acquisitions

    def get_acquisition_datetime(self):
        return self.metadata.get('DateCreated')

    def get_significant_bits(self):
        return self.bits_per_pixel

    def print_timepoint_well_matrix(self):
        s = ''

        time_points = self.time_points
        wells = [well for well in self.wells]

        well_matrix = []
        for timepoint in time_points:
            wells_at_timepoint = self.db.fetch_all('''
                SELECT DISTINCT Well.Name FROM SourceImageBase
                JOIN Well ON SourceImageBase.ZoneIndex = Well.ZoneIndex
                WHERE TimeSeriesElementId = ?
            ''', [timepoint], return_dicts=False)

            row = ['+' if well in wells_at_timepoint else ' ' for well in wells]
            well_matrix.append(row)

        header = ' '.join([pad_leading_zero(well) for well in wells])
        s += 'Timepoint ' + header + '\n'
        for idx, row in enumerate(well_matrix):
            s += f'{time_points[idx]:9}  ' + '   '.join(row) + '\n'
        return s

    def get_acquisition_metadata(self):
        return self.acquisition_metadata

    def close(self):
        """
        Closes the database connection.
        """
        self.db.close()

data = None instance-attribute

data_level = None instance-attribute

data_well_id = None instance-attribute

db = DbReader(self.uri) instance-attribute

dim_order = 'tczyx' instance-attribute

__init__(uri, metadata={})

Source code in src/biomero_converter/ImageDbSource.py
17
18
19
20
21
22
23
def __init__(self, uri, metadata={}):
    super().__init__(uri, metadata)
    self.db = DbReader(self.uri)
    self.data = None
    self.data_well_id = None
    self.data_level = None
    self.dim_order = 'tczyx'

close()

Closes the database connection.

Source code in src/biomero_converter/ImageDbSource.py
384
385
386
387
388
def close(self):
    """
    Closes the database connection.
    """
    self.db.close()

get_acquisition_datetime()

Source code in src/biomero_converter/ImageDbSource.py
352
353
def get_acquisition_datetime(self):
    return self.metadata.get('DateCreated')

get_acquisition_metadata()

Source code in src/biomero_converter/ImageDbSource.py
381
382
def get_acquisition_metadata(self):
    return self.acquisition_metadata

get_acquisitions()

Source code in src/biomero_converter/ImageDbSource.py
340
341
342
343
344
345
346
347
348
349
350
def get_acquisitions(self):
    acquisitions = []
    for index, acq in enumerate(self.acquisitions):
        acquisitions.append({
            'id': index,
            'name': acq['Name'],
            'description': acq['Description'],
            'date_created': acq['DateCreated'].isoformat(),
            'date_modified': acq['DateModified'].isoformat()
        })
    return acquisitions

get_channels()

Source code in src/biomero_converter/ImageDbSource.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def get_channels(self):
    channels = []
    for channel0 in self.channels:
        channel = {}
        if 'Dye' in channel0 and channel0['Dye']:
            channel['label'] = channel0['Dye']
        if 'Color' in channel0:
            channel['color'] = hexrgb_to_rgba(channel0['Color'].lstrip('#'))
        emission = channel0.get('Emission')
        if emission:
            channel['emission_wavelength'] = emission
            channel['emission_wavelength_unit'] = 'nm'
        excitation = channel0.get('Excitation')
        if excitation:
            channel['excitation_wavelength'] = excitation
            channel['excitation_wavelength_unit'] = 'nm'
        channels.append(channel)
    return channels

get_columns()

Source code in src/biomero_converter/ImageDbSource.py
288
289
def get_columns(self):
    return self.columns

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Source code in src/biomero_converter/ImageDbSource.py
272
273
274
275
276
277
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    if not (well_id == self.data_well_id and level == self.data_level):
        self._assemble_image_data(self._read_well_info(well_id, level=level))
        self.data_well_id = well_id
        self.data_level = level
    return redimension_data(self._extract_site(field_id), self.dim_order, dim_order)

get_dim_order()

Source code in src/biomero_converter/ImageDbSource.py
300
301
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/ImageDbSource.py
303
304
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/ImageDbSource.py
297
298
def get_fields(self):
    return self.fields

get_name()

Source code in src/biomero_converter/ImageDbSource.py
279
280
281
282
283
def get_name(self):
    name = self.metadata.get('Name')
    if not name:
        name = splitall(os.path.splitext(self.uri)[0])[-2]
    return name

get_nchannels()

Source code in src/biomero_converter/ImageDbSource.py
334
335
def get_nchannels(self):
    return max(self.nchannels, 1)

get_pixel_size_um()

Source code in src/biomero_converter/ImageDbSource.py
306
307
def get_pixel_size_um(self):
    return {'x': self.pixel_size, 'y': self.pixel_size}

get_position_um(well_id=None, level=0)

Source code in src/biomero_converter/ImageDbSource.py
309
310
311
312
313
def get_position_um(self, well_id=None, level=0):
    well = self.wells[well_id]
    x = well.get('CoordX', 0) * self.widths[level] * self.pixel_size
    y = well.get('CoordY', 0) * self.heights[level] * self.pixel_size
    return {'x': x, 'y': y}

get_rows()

Source code in src/biomero_converter/ImageDbSource.py
285
286
def get_rows(self):
    return self.rows

get_scales()

Source code in src/biomero_converter/ImageDbSource.py
39
40
def get_scales(self):
    return self.scales

get_shape()

Source code in src/biomero_converter/ImageDbSource.py
33
34
def get_shape(self):
    return self.shape

get_shapes()

Source code in src/biomero_converter/ImageDbSource.py
36
37
def get_shapes(self):
    return self.shapes

get_significant_bits()

Source code in src/biomero_converter/ImageDbSource.py
355
356
def get_significant_bits(self):
    return self.bits_per_pixel

get_time_points()

Source code in src/biomero_converter/ImageDbSource.py
294
295
def get_time_points(self):
    return self.time_points

get_wells()

Source code in src/biomero_converter/ImageDbSource.py
291
292
def get_wells(self):
    return list(self.wells)

init_metadata()

Source code in src/biomero_converter/ImageDbSource.py
25
26
27
28
29
30
31
def init_metadata(self):
    self._get_time_series_info()
    self._get_experiment_metadata()
    self._get_well_info()
    self._get_image_info()
    self._get_sizes()
    return self.metadata

is_rgb()

Source code in src/biomero_converter/ImageDbSource.py
337
338
def is_rgb(self):
    return False

is_screen()

Source code in src/biomero_converter/ImageDbSource.py
269
270
def is_screen(self):
    return len(self.wells) > 0

print_timepoint_well_matrix()

Source code in src/biomero_converter/ImageDbSource.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
def print_timepoint_well_matrix(self):
    s = ''

    time_points = self.time_points
    wells = [well for well in self.wells]

    well_matrix = []
    for timepoint in time_points:
        wells_at_timepoint = self.db.fetch_all('''
            SELECT DISTINCT Well.Name FROM SourceImageBase
            JOIN Well ON SourceImageBase.ZoneIndex = Well.ZoneIndex
            WHERE TimeSeriesElementId = ?
        ''', [timepoint], return_dicts=False)

        row = ['+' if well in wells_at_timepoint else ' ' for well in wells]
        well_matrix.append(row)

    header = ' '.join([pad_leading_zero(well) for well in wells])
    s += 'Timepoint ' + header + '\n'
    for idx, row in enumerate(well_matrix):
        s += f'{time_points[idx]:9}  ' + '   '.join(row) + '\n'
    return s

camel_to_snake(name)

Source code in src/biomero_converter/util.py
303
304
305
def camel_to_snake(name):
    name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
    return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()

camel_to_snake_keys_dict(dct)

Source code in src/biomero_converter/util.py
295
296
297
298
299
300
def camel_to_snake_keys_dict(dct):
    if isinstance(dct, dict):
        result = {camel_to_snake(key): camel_to_snake_keys_dict(value) for key, value in dct.items()}
    else:
        result = dct
    return result

convert_dotnet_ticks_to_datetime(net_ticks)

Source code in src/biomero_converter/util.py
175
176
def convert_dotnet_ticks_to_datetime(net_ticks):
    return datetime(1, 1, 1) + timedelta(microseconds=net_ticks // 10)

convert_to_s(value, unit)

Source code in src/biomero_converter/util.py
319
320
321
322
323
324
def convert_to_s(value, unit):
    conversions = {
        'ns': 1e-9, 'µs': 1e-6, 'us': 1e-6, 'ms': 1e-3,
        's': 1, 'min': 60, 'h': 3600, 'd': 86400
    }
    return value * conversions.get(unit, 1)

convert_to_um(value, unit)

Source code in src/biomero_converter/util.py
308
309
310
311
312
313
314
315
316
def convert_to_um(value, unit):
    conversions = {
        'nm': 1e-3, 'nanometer': 1e-3,
        'µm': 1, 'um': 1, 'micrometer': 1, 'micron': 1,
        'mm': 1e3, 'millimeter': 1e3,
        'cm': 1e4, 'centimeter': 1e4,
        'm': 1e6, 'meter': 1e6
    }
    return value * conversions.get(unit, 1)

dicom_to_dict(obj)

Source code in src/biomero_converter/util.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def dicom_to_dict(obj):
    # Recursively convert a pydicom Dataset (or element value) to plain python types, using duck typing:
    # Dataset -> dict, Sequence / MultiValue -> list, DS / IS -> float / int, PersonName / UID -> str
    if hasattr(obj, 'iterall'):
        # Dataset: iterating yields its (top-level) data elements
        return {elem.keyword: dicom_to_dict(elem.value) for elem in obj
                if elem.keyword and elem.keyword != 'PixelData'}
    if obj is None or isinstance(obj, (bool, bytes)):
        return obj
    if isinstance(obj, int):
        return int(obj)
    if isinstance(obj, (float, Decimal)):
        return float(obj)
    if isinstance(obj, str):
        return str(obj)
    if isinstance(obj, Sequence):
        return [dicom_to_dict(item) for item in obj]
    return str(obj)

ensure_list(item)

Source code in src/biomero_converter/util.py
11
12
13
14
def ensure_list(item):
    if not isinstance(item, (list, tuple)):
        item = [item]
    return item

fix_bad_micro_value(value)

Source code in src/biomero_converter/util.py
365
366
367
368
def fix_bad_micro_value(value):
    if isinstance(value, str) and '\xa6\xcc' in value:
        value = value.replace('\xa6\xcc', '\xb5')
    return value

flatten_dict(dct, prefix='')

Source code in src/biomero_converter/util.py
281
282
283
284
285
286
287
288
289
290
291
292
def flatten_dict(dct, prefix=''):
    flat_dct = {}
    for key, value in dct.items():
        full_key = prefix + ':' + key if prefix else key
        if isinstance(value, dict):
            flat_dct.update(flatten_dict(value, full_key))
        elif isinstance(value, (list, tuple)):
            for index, item in enumerate(value):
                flat_dct.update(flatten_dict({str(index): item}, full_key))
        else:
            flat_dct[full_key] = value
    return flat_dct

get_bits_type(nbits)

Source code in src/biomero_converter/util.py
77
78
79
80
81
82
83
84
85
86
def get_bits_type(nbits):
    if nbits <= 8:
        dtype = np.uint8
    elif nbits <= 16:
        dtype = np.uint16
    elif nbits <= 32:
        dtype = np.uint32
    else:
        dtype = np.uint64
    return np.dtype(dtype)

get_filetitle(filename)

Source code in src/biomero_converter/util.py
93
94
def get_filetitle(filename):
    return os.path.basename(os.path.splitext(filename)[0])

get_level_from_scale(source_scales, target_scale=1)

Source code in src/biomero_converter/util.py
67
68
69
70
71
72
73
74
def get_level_from_scale(source_scales, target_scale=1):
    best_level_scale = 0, target_scale
    for level, scale in enumerate(source_scales):
        if np.isclose(scale, target_scale, rtol=1e-4):
            return level, 1
        if scale <= target_scale:
            best_level_scale = level, target_scale / scale
    return best_level_scale

get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size)

Source code in src/biomero_converter/util.py
43
44
45
46
47
48
49
50
51
52
53
54
55
def get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size):
    x_index = dim_order.index('x')
    y_index = dim_order.index('y')
    slices = [slice(None)] * len(dim_order)
    if 't' in dim_order:
        slices[dim_order.index('t')] = t
    if 'c' in dim_order and c is not None:  # None: all channels
        slices[dim_order.index('c')] = c
    if 'z' in dim_order:
        slices[dim_order.index('z')] = z
    slices[y_index] = slice(y, y + y_size)
    slices[x_index] = slice(x, x + x_size)
    return data[tuple(slices)]

get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1)

Source code in src/biomero_converter/util.py
58
59
60
61
62
63
64
def get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1):
    # limit number of pyramid levels, keeping the smallest level at least min_level_size pixels
    min_size = min(shape[dim_order.index('x')], shape[dim_order.index('y')])
    levels = 0
    while levels < max_levels and min_size / downscale ** (levels + 1) >= min_level_size:
        levels += 1
    return levels

get_rows_cols_plate(nwells)

Source code in src/biomero_converter/util.py
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_rows_cols_plate(nwells):
    nrows_cols = {
        6: (2, 3),
        12: (3, 4),
        24: (4, 6),
        48: (6, 8),
        96: (8, 12),
        384: (16, 24)
    }
    nrows, ncols = nrows_cols[nwells]
    rows = [chr(ord('A') + i) for i in range(nrows)]
    cols = [str(i + 1) for i in range(ncols)]
    return rows, cols

pad_leading_zero(input_string, num_digits=2)

Source code in src/biomero_converter/util.py
129
130
131
132
133
134
135
136
137
138
139
def pad_leading_zero(input_string, num_digits=2):
    output = str(input_string)
    is_well = not output[0].isdigit()
    if is_well:
        row, col = split_well_name(output, remove_leading_zeros=True)
        output = str(col)
    while len(output) < num_digits:
        output = '0' + output
    if is_well:
        output = row + output
    return output

parse_datetime(value)

A datetime from an ISO 8601 or other common date text, or None if it is none.

Source code in src/biomero_converter/util.py
162
163
164
165
166
167
168
169
170
171
172
def parse_datetime(value):
    """A datetime from an ISO 8601 or other common date text, or None if it is none."""
    if not value or isinstance(value, datetime):
        return value or None
    try:
        return datetime.fromisoformat(str(value))
    except ValueError:
        try:
            return dateutil.parser.parse(str(value))
        except (ValueError, OverflowError):
            return None

print_dict(value, tab=0, max_len=250, bullet=False)

Source code in src/biomero_converter/util.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def print_dict(value, tab=0, max_len=250, bullet=False):
    s = ''
    if isinstance(value, dict):
        for key, subvalue in value.items():
            s += '\n'
            if bullet:
                s += '-'
                bullet = False
            s += '\t' * tab + str(key) + ': '
            if isinstance(subvalue, dict):
                s += print_dict(subvalue, tab+1)
            elif isinstance(subvalue, list):
                for v in subvalue:
                    s += print_dict(v, tab+1, bullet=True)
            else:
                subvalue = str(subvalue)
                if len(subvalue) > max_len:
                    subvalue = subvalue[:max_len] + '...'
                s += subvalue
    else:
        s += str(value) + ' '
    return s

print_hbytes(nbytes)

Source code in src/biomero_converter/util.py
351
352
353
354
355
356
357
358
359
360
361
362
def print_hbytes(nbytes):
    exps = ['', 'K', 'M', 'G', 'T', 'P', 'E']
    div = 1024
    exp = 0
    while nbytes > div:
        nbytes /= div
        exp += 1
    if exp < len(exps):
        e = exps[exp]
    else:
        e = f'e{exp * 3}'
    return f'{nbytes:.1f}{e}B'

redimension_data(data, old_order, new_order, **indices)

Source code in src/biomero_converter/util.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def redimension_data(data, old_order, new_order, **indices):
    # able to provide optional dimension values e.g. t=0, z=0
    if new_order == old_order:
        return data

    new_data = data
    order = old_order
    # remove
    for o in old_order:
        if o not in new_order:
            index = order.index(o)
            dim_value = indices.get(o, 0)
            new_data = np.take(new_data, indices=dim_value, axis=index)
            order = order[:index] + order[index + 1:]
    # add
    for o in new_order:
        if o not in order:
            new_data = np.expand_dims(new_data, 0)
            order = o + order
    # move
    old_indices = [order.index(o) for o in new_order]
    new_indices = list(range(len(new_order)))
    new_data = np.moveaxis(new_data, old_indices, new_indices)
    return new_data

remove_key_prefix(obj, prefix)

Source code in src/biomero_converter/util.py
271
272
273
274
275
276
277
278
def remove_key_prefix(obj, prefix):
    # recursively remove prefix from dict keys
    if isinstance(obj, dict):
        return {(key[len(prefix):] if key.startswith(prefix) and len(key) > len(prefix) else key):
                remove_key_prefix(value, prefix) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [remove_key_prefix(item, prefix) for item in obj]
    return obj

split_well_name(well_name, remove_leading_zeros=True, col_as_int=False)

Source code in src/biomero_converter/util.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def split_well_name(well_name, remove_leading_zeros=True, col_as_int=False):
    matches = re.findall(r'(\D+)(\d+)', well_name)
    if len(matches) > 0:
        row, col = matches[0]
        if col_as_int or remove_leading_zeros:
            try:
                col = int(col)
            except ValueError:
                pass
        if not col_as_int:
            col = str(col)
        return row, col
    else:
        raise ValueError(f"Invalid well name format: {well_name}. Expected format like 'A1', 'B2', etc.")

splitall(path)

Source code in src/biomero_converter/util.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def splitall(path):
    allparts = []
    while True:
        parts = os.path.split(path)
        if parts[0] == path:  # sentinel for absolute paths
            allparts.insert(0, parts[0])
            break
        elif parts[1] == path: # sentinel for relative paths
            allparts.insert(0, parts[1])
            break
        else:
            path = parts[0]
            allparts.insert(0, parts[1])
    return allparts

strip_leading_zeros(well_name)

Source code in src/biomero_converter/util.py
142
143
144
def strip_leading_zeros(well_name):
    row, col = split_well_name(well_name, remove_leading_zeros=True)
    return f'{row}{col}'

to_plain_types(obj)

Source code in src/biomero_converter/util.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def to_plain_types(obj):
    # Recursively convert metadata to plain python types (e.g. for yaml): tuple -> list, numpy -> python,
    # bytes -> str, Enum -> name; datetime is kept, anything else unknown becomes str
    if isinstance(obj, dict):
        return {str(key): to_plain_types(value) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [to_plain_types(item) for item in obj]
    if isinstance(obj, np.ndarray):
        return to_plain_types(obj.tolist())
    if isinstance(obj, np.generic):
        return obj.item()
    if isinstance(obj, bytes):
        return obj.decode('utf-8', errors='replace')
    if isinstance(obj, Enum):
        return obj.name
    if obj is None or isinstance(obj, (bool, int, float, str, datetime)):
        return obj
    return str(obj)

validate_filename(filename)

Source code in src/biomero_converter/util.py
89
90
def validate_filename(filename):
    return re.sub(r'[^\w_.)(-]', '_', filename)

without_keys(value, keys)

Source code in src/biomero_converter/util.py
262
263
264
265
266
267
268
def without_keys(value, keys):
    # recursively drop the entries named by keys
    if isinstance(value, dict):
        return {key: without_keys(item, keys) for key, item in value.items() if key not in keys}
    if isinstance(value, (list, tuple)):
        return [without_keys(item, keys) for item in value]
    return value

xml_content_to_dict(element)

Source code in src/biomero_converter/util.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def xml_content_to_dict(element):
    key = element.tag
    children = list(element)
    if key == 'Array':
        res = [xml_content_to_dict(child) for child in children]
        return res
    if len(children) > 0:
        if children[0].tag == 'Array':
            value = []
        else:
            value = {}
        for child in children:
            child_value = xml_content_to_dict(child)
            if isinstance(child_value, list):
                value.extend(child_value)
            else:
                value |= child_value
    else:
        value = element.text
        if value is not None:
            if '"' in value:
                value = value.replace('"', '')
            else:
                for t in (float, int, bool):
                    try:
                        if t == bool:
                            if value.lower() == 'true':
                                value = True
                            if value.lower() == 'false':
                                value = False
                        else:
                            value = t(value)
                        break
                    except (TypeError, ValueError):
                        pass

    if key == 'DataObject':
        key = element.attrib['ObjectType']
    if key == 'Attribute':
        key = element.attrib['Name']
    return {key: value}

ImageSource

ImageSource

Bases: ABC

Abstract base class for image sources.

Source code in src/biomero_converter/ImageSource.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
class ImageSource(ABC):
    """
    Abstract base class for image sources.
    """

    def __init__(self, uri, metadata={}):
        """
        Initialize ImageSource.

        Args:
            uri (str): Path to the image source.
            metadata (dict): Optional metadata dictionary.
        """
        self.uri = uri
        self.metadata = metadata

    def init_metadata(self):
        """
        Initialize and load metadata.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'init_metadata' method must be implemented by subclasses.")

    def is_screen(self):
        """
        Check if the source is a screen (multi-well).

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'is_screen' method must be implemented by subclasses.")

    def get_shape(self):
        """
        Get the shape of the image data.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_shape' method must be implemented by subclasses.")

    def get_shapes(self):
        """
        Get a list of shapes corresponding to the image data levels.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_shapes' method must be implemented by subclasses.")

    def get_scales(self):
        """
        Get the list of image scales.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_scales' method must be implemented by subclasses.")

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        """
        Get image data for a well and field.

        Args:
            dim_order: Dimension order of data
            level (int, optional): Image resolution level
            well_id (str, optional): Well identifier
            field_id (int, optional): Field identifier
            kwargs (optional): Format specific keyword arguments.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_data' method must be implemented by subclasses.")

    def get_data_as_dask(self, dim_order, level=0, **kwargs):
        """
        Get image data (WSI) as dask array.

        Args:
            dim_order: Dimension order of data
            level (int, optional): Image resolution level
            kwargs (optional): Format specific keyword arguments.
        """
        return self.get_data(dim_order, level=level, **kwargs)

    def get_data_as_generator(self, dim_order, **kwargs):
        """
        Get image data (WSI) as generator.

        Args:
            dim_order: Dimension order of data
            kwargs (optional): Format specific keyword arguments.
        """
        return self.get_data(dim_order, **kwargs)

    def get_image_window(self, window_scanner, well_id=None, field_id=None, data=None):
        """
        Get image value range window (for a well & field or from provided data).

        Args:
            window_scanner (WindowScanner): WindowScanner object to compute window.
            well_id (str, optional): Well identifier
            field_id (int, optional): Field identifier
            data (ndarray, optional): Image data to compute window from.
        """
        # For RGB(A) uint8 images don't change color value range
        if self.get_dtype() != np.uint8:
            if data is None:
                for level, shape in enumerate(self.get_shapes()):
                    if np.prod(shape) * self.get_dtype().itemsize < 1e8:  # less than 100 MB
                        data = self.get_data(self.get_dim_order(), well_id=well_id, field_id=field_id, level=level)
                        break
            if data is not None:
                window_scanner.process(data, self.get_dim_order())
        return window_scanner.get_window()

    def get_name(self):
        """
        Get the name of the image source.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_name' method must be implemented by subclasses.")

    def get_dim_order(self):
        """
        Get the dimension order string.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_dim_order' method must be implemented by subclasses.")

    def get_dtype(self):
        """
        Get the numpy dtype of the image data.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_dtype' method must be implemented by subclasses.")

    def get_pixel_size_um(self):
        """
        Get the pixel size in micrometers.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_pixel_size_um' method must be implemented by subclasses.")

    def get_position_um(self, well_id=None):
        """
        Get the position in micrometers for a well.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_position_um' method must be implemented by subclasses.")

    def get_channels(self):
        """
        Get channel metadata in NGFF format, color provided as RGBA list with values between 0 and 1
        e.g. white = [1, 1, 1, 1]

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_channels' method must be implemented by subclasses.")

    def get_nchannels(self):
        """
        Get the number of channels.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_nchannels' method must be implemented by subclasses.")

    def is_rgb(self):
        """
        Check if the source is a RGB(A) image.
        """
        raise NotImplementedError("The 'is_rgb' method must be implemented by subclasses.")

    def get_rows(self):
        """
        Get the list of row identifiers.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_rows' method must be implemented by subclasses.")

    def get_columns(self):
        """
        Get the list of column identifiers.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_columns' method must be implemented by subclasses.")

    def get_wells(self):
        """
        Get the list of well identifiers.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_wells' method must be implemented by subclasses.")

    def get_time_points(self):
        """
        Get the list of time points.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_time_points' method must be implemented by subclasses.")

    def get_fields(self):
        """
        Get the list of field indices.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_fields' method must be implemented by subclasses.")

    def get_acquisitions(self):
        """
        Get acquisition metadata.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_acquisitions' method must be implemented by subclasses.")

    def get_acquisition_datetime(self):
        """
        Get the acquisition datetime.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_acquisition_datetime' method must be implemented by subclasses.")

    def get_acquisition_end_datetime(self):
        """
        Get the datetime the acquisition ended, when its last image was collected.

        Returns:
            datetime: End datetime, or None if the metadata does not give it.
        """
        return None

    def get_significant_bits(self):
        """
        Get the number of significant bits in the image data.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("The 'get_significant_bits' method must be implemented by subclasses.")

    def get_metadata(self):
        """
        Get the metadata dictionary.

        Returns:
            dict: Metadata dictionary.
        """
        return self.metadata

    def get_acquisition_metadata(self):
        """
        Get microscope information. This can include details such as the microscope model, objective lens, and other relevant information.
        """
        return {}

    def get_source_metadata(self):
        """
        Get all of the source's metadata, but what only lays out or encodes the pixel data, for the imaging
        metadata converter: by default the acquisition metadata.
        """
        return self.get_acquisition_metadata()

    def get_model_metadata(self):
        """
        Get the source metadata mapped onto the common imaging metadata model
        (https://github.com/NL-BioImaging/imaging-metadata-converter). Unmapped fields keep their original path.

        Returns:
            dict: Nested model metadata, e.g. {'Instrument': {'Manufacturer': ..., 'Model': ...}, ...}.
        """
        # converted once, as the OME writer and the RO-Crate both ask for it; as plain data (bytes as text, numpy
        # values as numbers), as the converter's JSON examples hold it
        if getattr(self, '_model_metadata', None) is None:
            self._model_metadata = convert_metadata(to_plain_types(self.get_source_metadata()))
        return self._model_metadata

    def get_model_acquisition_datetime(self):
        """
        Get the acquisition datetime the model metadata gives (Image.AcquisitionDate), from the vendor metadata.

        Returns:
            datetime: Acquisition datetime, or None if the metadata does not give it.
        """
        images = ensure_list(self.get_model_metadata().get('Image') or [])
        if images and isinstance(images[0], dict):
            return parse_datetime(images[0].get('AcquisitionDate'))
        return None

    def get_total_data_size(self):
        """
        Get the estimated total data size.

        Returns:
            int: Total data size in bytes.
        """
        image_size = np.prod(self.get_shape()) * np.dtype(self.get_dtype()).itemsize
        if self.is_screen():
            nwells = len(self.get_wells())
            nfields = len(self.get_fields())
            total_size = image_size * nwells * nfields
        else:
            total_size = image_size
        return total_size

    def close(self):
        """
        Close the image source.
        """
        pass

    def print_well_matrix(self):
        """
        Print a matrix representation of the well plate.
        """
        s = ''

        rows, cols = self.get_rows(), self.get_columns()
        used_wells = [well for well in self.get_wells()]

        well_matrix = []
        for row_id in rows:
            row = ''
            for col_id in cols:
                well_id = f'{row_id}{col_id}'
                row += '+' if well_id in used_wells else ' '
            well_matrix.append(row)

        header = ' '.join([pad_leading_zero(col) for col in cols])
        s += ' ' + header + '\n'
        for idx, row in enumerate(well_matrix):
            s += f'{rows[idx]} ' + '  '.join(row) + '\n'
        return s

metadata = metadata instance-attribute

uri = uri instance-attribute

__init__(uri, metadata={})

Initialize ImageSource.

Parameters:

Name Type Description Default
uri str

Path to the image source.

required
metadata dict

Optional metadata dictionary.

{}
Source code in src/biomero_converter/ImageSource.py
13
14
15
16
17
18
19
20
21
22
def __init__(self, uri, metadata={}):
    """
    Initialize ImageSource.

    Args:
        uri (str): Path to the image source.
        metadata (dict): Optional metadata dictionary.
    """
    self.uri = uri
    self.metadata = metadata

close()

Close the image source.

Source code in src/biomero_converter/ImageSource.py
342
343
344
345
346
def close(self):
    """
    Close the image source.
    """
    pass

get_acquisition_datetime()

Get the acquisition datetime.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
251
252
253
254
255
256
257
258
def get_acquisition_datetime(self):
    """
    Get the acquisition datetime.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_acquisition_datetime' method must be implemented by subclasses.")

get_acquisition_end_datetime()

Get the datetime the acquisition ended, when its last image was collected.

Returns:

Name Type Description
datetime

End datetime, or None if the metadata does not give it.

Source code in src/biomero_converter/ImageSource.py
260
261
262
263
264
265
266
267
def get_acquisition_end_datetime(self):
    """
    Get the datetime the acquisition ended, when its last image was collected.

    Returns:
        datetime: End datetime, or None if the metadata does not give it.
    """
    return None

get_acquisition_metadata()

Get microscope information. This can include details such as the microscope model, objective lens, and other relevant information.

Source code in src/biomero_converter/ImageSource.py
287
288
289
290
291
def get_acquisition_metadata(self):
    """
    Get microscope information. This can include details such as the microscope model, objective lens, and other relevant information.
    """
    return {}

get_acquisitions()

Get acquisition metadata.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
242
243
244
245
246
247
248
249
def get_acquisitions(self):
    """
    Get acquisition metadata.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_acquisitions' method must be implemented by subclasses.")

get_channels()

Get channel metadata in NGFF format, color provided as RGBA list with values between 0 and 1 e.g. white = [1, 1, 1, 1]

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
172
173
174
175
176
177
178
179
180
def get_channels(self):
    """
    Get channel metadata in NGFF format, color provided as RGBA list with values between 0 and 1
    e.g. white = [1, 1, 1, 1]

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_channels' method must be implemented by subclasses.")

get_columns()

Get the list of column identifiers.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
206
207
208
209
210
211
212
213
def get_columns(self):
    """
    Get the list of column identifiers.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_columns' method must be implemented by subclasses.")

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Get image data for a well and field.

Parameters:

Name Type Description Default
dim_order

Dimension order of data

required
level int

Image resolution level

0
well_id str

Well identifier

None
field_id int

Field identifier

None
kwargs optional

Format specific keyword arguments.

{}

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    """
    Get image data for a well and field.

    Args:
        dim_order: Dimension order of data
        level (int, optional): Image resolution level
        well_id (str, optional): Well identifier
        field_id (int, optional): Field identifier
        kwargs (optional): Format specific keyword arguments.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_data' method must be implemented by subclasses.")

get_data_as_dask(dim_order, level=0, **kwargs)

Get image data (WSI) as dask array.

Parameters:

Name Type Description Default
dim_order

Dimension order of data

required
level int

Image resolution level

0
kwargs optional

Format specific keyword arguments.

{}
Source code in src/biomero_converter/ImageSource.py
85
86
87
88
89
90
91
92
93
94
def get_data_as_dask(self, dim_order, level=0, **kwargs):
    """
    Get image data (WSI) as dask array.

    Args:
        dim_order: Dimension order of data
        level (int, optional): Image resolution level
        kwargs (optional): Format specific keyword arguments.
    """
    return self.get_data(dim_order, level=level, **kwargs)

get_data_as_generator(dim_order, **kwargs)

Get image data (WSI) as generator.

Parameters:

Name Type Description Default
dim_order

Dimension order of data

required
kwargs optional

Format specific keyword arguments.

{}
Source code in src/biomero_converter/ImageSource.py
 96
 97
 98
 99
100
101
102
103
104
def get_data_as_generator(self, dim_order, **kwargs):
    """
    Get image data (WSI) as generator.

    Args:
        dim_order: Dimension order of data
        kwargs (optional): Format specific keyword arguments.
    """
    return self.get_data(dim_order, **kwargs)

get_dim_order()

Get the dimension order string.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
136
137
138
139
140
141
142
143
def get_dim_order(self):
    """
    Get the dimension order string.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_dim_order' method must be implemented by subclasses.")

get_dtype()

Get the numpy dtype of the image data.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
145
146
147
148
149
150
151
152
def get_dtype(self):
    """
    Get the numpy dtype of the image data.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_dtype' method must be implemented by subclasses.")

get_fields()

Get the list of field indices.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
233
234
235
236
237
238
239
240
def get_fields(self):
    """
    Get the list of field indices.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_fields' method must be implemented by subclasses.")

get_image_window(window_scanner, well_id=None, field_id=None, data=None)

Get image value range window (for a well & field or from provided data).

Parameters:

Name Type Description Default
window_scanner WindowScanner

WindowScanner object to compute window.

required
well_id str

Well identifier

None
field_id int

Field identifier

None
data ndarray

Image data to compute window from.

None
Source code in src/biomero_converter/ImageSource.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def get_image_window(self, window_scanner, well_id=None, field_id=None, data=None):
    """
    Get image value range window (for a well & field or from provided data).

    Args:
        window_scanner (WindowScanner): WindowScanner object to compute window.
        well_id (str, optional): Well identifier
        field_id (int, optional): Field identifier
        data (ndarray, optional): Image data to compute window from.
    """
    # For RGB(A) uint8 images don't change color value range
    if self.get_dtype() != np.uint8:
        if data is None:
            for level, shape in enumerate(self.get_shapes()):
                if np.prod(shape) * self.get_dtype().itemsize < 1e8:  # less than 100 MB
                    data = self.get_data(self.get_dim_order(), well_id=well_id, field_id=field_id, level=level)
                    break
        if data is not None:
            window_scanner.process(data, self.get_dim_order())
    return window_scanner.get_window()

get_metadata()

Get the metadata dictionary.

Returns:

Name Type Description
dict

Metadata dictionary.

Source code in src/biomero_converter/ImageSource.py
278
279
280
281
282
283
284
285
def get_metadata(self):
    """
    Get the metadata dictionary.

    Returns:
        dict: Metadata dictionary.
    """
    return self.metadata

get_model_acquisition_datetime()

Get the acquisition datetime the model metadata gives (Image.AcquisitionDate), from the vendor metadata.

Returns:

Name Type Description
datetime

Acquisition datetime, or None if the metadata does not give it.

Source code in src/biomero_converter/ImageSource.py
314
315
316
317
318
319
320
321
322
323
324
def get_model_acquisition_datetime(self):
    """
    Get the acquisition datetime the model metadata gives (Image.AcquisitionDate), from the vendor metadata.

    Returns:
        datetime: Acquisition datetime, or None if the metadata does not give it.
    """
    images = ensure_list(self.get_model_metadata().get('Image') or [])
    if images and isinstance(images[0], dict):
        return parse_datetime(images[0].get('AcquisitionDate'))
    return None

get_model_metadata()

Get the source metadata mapped onto the common imaging metadata model (https://github.com/NL-BioImaging/imaging-metadata-converter). Unmapped fields keep their original path.

Returns:

Name Type Description
dict

Nested model metadata, e.g. {'Instrument': {'Manufacturer': ..., 'Model': ...}, ...}.

Source code in src/biomero_converter/ImageSource.py
300
301
302
303
304
305
306
307
308
309
310
311
312
def get_model_metadata(self):
    """
    Get the source metadata mapped onto the common imaging metadata model
    (https://github.com/NL-BioImaging/imaging-metadata-converter). Unmapped fields keep their original path.

    Returns:
        dict: Nested model metadata, e.g. {'Instrument': {'Manufacturer': ..., 'Model': ...}, ...}.
    """
    # converted once, as the OME writer and the RO-Crate both ask for it; as plain data (bytes as text, numpy
    # values as numbers), as the converter's JSON examples hold it
    if getattr(self, '_model_metadata', None) is None:
        self._model_metadata = convert_metadata(to_plain_types(self.get_source_metadata()))
    return self._model_metadata

get_name()

Get the name of the image source.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
127
128
129
130
131
132
133
134
def get_name(self):
    """
    Get the name of the image source.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_name' method must be implemented by subclasses.")

get_nchannels()

Get the number of channels.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
182
183
184
185
186
187
188
189
def get_nchannels(self):
    """
    Get the number of channels.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_nchannels' method must be implemented by subclasses.")

get_pixel_size_um()

Get the pixel size in micrometers.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
154
155
156
157
158
159
160
161
def get_pixel_size_um(self):
    """
    Get the pixel size in micrometers.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_pixel_size_um' method must be implemented by subclasses.")

get_position_um(well_id=None)

Get the position in micrometers for a well.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
163
164
165
166
167
168
169
170
def get_position_um(self, well_id=None):
    """
    Get the position in micrometers for a well.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_position_um' method must be implemented by subclasses.")

get_rows()

Get the list of row identifiers.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
197
198
199
200
201
202
203
204
def get_rows(self):
    """
    Get the list of row identifiers.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_rows' method must be implemented by subclasses.")

get_scales()

Get the list of image scales.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
60
61
62
63
64
65
66
67
def get_scales(self):
    """
    Get the list of image scales.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_scales' method must be implemented by subclasses.")

get_shape()

Get the shape of the image data.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
42
43
44
45
46
47
48
49
def get_shape(self):
    """
    Get the shape of the image data.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_shape' method must be implemented by subclasses.")

get_shapes()

Get a list of shapes corresponding to the image data levels.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
51
52
53
54
55
56
57
58
def get_shapes(self):
    """
    Get a list of shapes corresponding to the image data levels.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_shapes' method must be implemented by subclasses.")

get_significant_bits()

Get the number of significant bits in the image data.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
269
270
271
272
273
274
275
276
def get_significant_bits(self):
    """
    Get the number of significant bits in the image data.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_significant_bits' method must be implemented by subclasses.")

get_source_metadata()

Get all of the source's metadata, but what only lays out or encodes the pixel data, for the imaging metadata converter: by default the acquisition metadata.

Source code in src/biomero_converter/ImageSource.py
293
294
295
296
297
298
def get_source_metadata(self):
    """
    Get all of the source's metadata, but what only lays out or encodes the pixel data, for the imaging
    metadata converter: by default the acquisition metadata.
    """
    return self.get_acquisition_metadata()

get_time_points()

Get the list of time points.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
224
225
226
227
228
229
230
231
def get_time_points(self):
    """
    Get the list of time points.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_time_points' method must be implemented by subclasses.")

get_total_data_size()

Get the estimated total data size.

Returns:

Name Type Description
int

Total data size in bytes.

Source code in src/biomero_converter/ImageSource.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def get_total_data_size(self):
    """
    Get the estimated total data size.

    Returns:
        int: Total data size in bytes.
    """
    image_size = np.prod(self.get_shape()) * np.dtype(self.get_dtype()).itemsize
    if self.is_screen():
        nwells = len(self.get_wells())
        nfields = len(self.get_fields())
        total_size = image_size * nwells * nfields
    else:
        total_size = image_size
    return total_size

get_wells()

Get the list of well identifiers.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
215
216
217
218
219
220
221
222
def get_wells(self):
    """
    Get the list of well identifiers.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'get_wells' method must be implemented by subclasses.")

init_metadata()

Initialize and load metadata.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
24
25
26
27
28
29
30
31
def init_metadata(self):
    """
    Initialize and load metadata.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'init_metadata' method must be implemented by subclasses.")

is_rgb()

Check if the source is a RGB(A) image.

Source code in src/biomero_converter/ImageSource.py
191
192
193
194
195
def is_rgb(self):
    """
    Check if the source is a RGB(A) image.
    """
    raise NotImplementedError("The 'is_rgb' method must be implemented by subclasses.")

is_screen()

Check if the source is a screen (multi-well).

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/biomero_converter/ImageSource.py
33
34
35
36
37
38
39
40
def is_screen(self):
    """
    Check if the source is a screen (multi-well).

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("The 'is_screen' method must be implemented by subclasses.")

print_well_matrix()

Print a matrix representation of the well plate.

Source code in src/biomero_converter/ImageSource.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def print_well_matrix(self):
    """
    Print a matrix representation of the well plate.
    """
    s = ''

    rows, cols = self.get_rows(), self.get_columns()
    used_wells = [well for well in self.get_wells()]

    well_matrix = []
    for row_id in rows:
        row = ''
        for col_id in cols:
            well_id = f'{row_id}{col_id}'
            row += '+' if well_id in used_wells else ' '
        well_matrix.append(row)

    header = ' '.join([pad_leading_zero(col) for col in cols])
    s += ' ' + header + '\n'
    for idx, row in enumerate(well_matrix):
        s += f'{rows[idx]} ' + '  '.join(row) + '\n'
    return s

IncucyteSource

IncucyteSource

Bases: ImageSource

ImageSource implementation for Incucyte data

Handles the specific directory structure: EssenFiles/ScanData/YYMM/DD/HHMM/XXXX/*.tif

Filenames follow pattern: WELL-FIELD-CHANNEL.tif e.g., A1-1-C1.tif, B2-1-Ph.tif

Note: Multiple plates can exist in the same archive, identified by the XXXX folder. Use plate_id parameter to select a specific plate, or use get_available_plates() to discover all plates in the archive.

Source code in src/biomero_converter/IncucyteSource.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
class IncucyteSource(ImageSource):
    """
    ImageSource implementation for Incucyte data

    Handles the specific directory structure:
    EssenFiles/ScanData/YYMM/DD/HHMM/XXXX/*.tif

    Filenames follow pattern: WELL-FIELD-CHANNEL.tif
    e.g., A1-1-C1.tif, B2-1-Ph.tif

    Note: Multiple plates can exist in the same archive, identified by the XXXX folder.
    Use plate_id parameter to select a specific plate, or use get_available_plates() 
    to discover all plates in the archive.
    """

    DIAG_ZIP_FILENAME = "Diag.zip"
    DIAG_LOG_FILENAME = "Diag.log"

    def __init__(self, uri, metadata={}, plate_id=None):
        """
        Initialize IncucyteSource.

        Args:
            uri (str): Path to the Incucyte archive folder
            metadata (dict): Optional metadata dictionary
            plate_id (str, optional): Specific plate ID to process (e.g., '700', '701').
                                     If None, will use the first available plate or all 
                                     if only one exists.
        """
        super().__init__(uri, metadata)
        self.plate_id = plate_id
        self.base_path = Path(self.uri)
        self.scan_data_path = self.base_path / "EssenFiles" / "ScanData"
        self._file_cache = {}
        self._file_caching = False
        # Default to True for filling missing images
        self.fill_missing_images = True

    @staticmethod
    def get_available_plates(uri):
        """
        Discover all available plate IDs in an Incucyte archive.

        Args:
            uri (str): Path to the Incucyte archive folder

        Returns:
            list: List of plate IDs (strings) found in the archive
        """
        base_path = Path(uri)
        scan_data_path = base_path / "EssenFiles" / "ScanData"

        if not scan_data_path.exists():
            raise ValueError(f"Scan data path not found: {scan_data_path}")

        plate_ids = set()

        # Navigate through the directory structure to find all plate IDs
        for year_month in scan_data_path.iterdir():
            if not year_month.is_dir():
                continue
            for day in year_month.iterdir():
                if not day.is_dir():
                    continue
                for time_dir in day.iterdir():
                    if not time_dir.is_dir():
                        continue
                    for plate_dir in time_dir.iterdir():
                        if plate_dir.is_dir():
                            plate_ids.add(plate_dir.name)

        return sorted(list(plate_ids))

    def enable_file_caching(self, file_caching=True):
        """
        Enable or disable file caching for image data.

        Args:
            file_caching (bool): If True, enable file caching; if False, disable it.
        """
        self._file_caching = file_caching
        if not file_caching:
            self._file_cache.clear()

    def _find_and_parse_diag_log(self):
        """
        Find the first Diag.zip in the scan data and parse it.

        Returns:
            dict: Parsed diag metadata or None if not found
        """
        # Look for first Diag.zip in the scan data
        diag_zip_files = list(self.scan_data_path.rglob(self.DIAG_ZIP_FILENAME))

        if diag_zip_files:
            results = self._parse_diag_log(diag_zip_files[0])
        else:
            results = None

        return results

    def _parse_diag_log(self, diag_zip_path):
        """
        Parse Diag.log from a Diag.zip file to extract imaging metadata.

        Args:
            diag_zip_path (Path): Path to Diag.zip file

        Returns:
            dict: Dictionary with 'pixel_sizes' (dict of mag->size),
                  'experiments' (dict of expid->metadata), or None if failed
        """
        try:
            with zipfile.ZipFile(diag_zip_path) as zip_ref:
                if self.DIAG_LOG_FILENAME not in zip_ref.namelist():
                    return None

                raw = zip_ref.read(self.DIAG_LOG_FILENAME)
                detection = chardet.detect(raw)
                content = raw.decode(detection['encoding'], errors='ignore')

                # Parse imaging specifications
                pixel_sizes = {}
                mag_pattern = r'(\d+)x:\s+.*?Image Resolution:\s+([\d.]+)\s+microns/pixel'
                for match in re.finditer(mag_pattern, content, re.DOTALL):
                    mag = match.group(1) + 'x'
                    pixel_size = float(match.group(2))
                    pixel_sizes[mag] = pixel_size

                # Parse experiment entries
                experiments = {}
                # Match ExpID and capture next 2 lines for Lmp info
                exp_pattern = r'ExpID=(\d+)[^\n]*Mag=(\d+x)[^\n]*(?:\n[^\n]*)?'
                for match in re.finditer(exp_pattern, content):
                    exp_id = match.group(1)
                    mag = match.group(2)

                    # Extract all exposure times from matched section
                    exp_section = match.group(0)
                    acq_times = re.findall(r'AcqTime=(\d+)', exp_section)

                    experiments[exp_id] = {
                        'magnification': mag,
                        'exposure_times_ms': [int(t) for t in acq_times] if acq_times else None,
                        'pixel_size_um': pixel_sizes.get(mag)
                    }

                results = {
                    'experiments': experiments,
                }

                nwell_raw = re.findall(r'(\d+)-well', content)
                if nwell_raw:
                    results['nwell_plate'] = int(Counter(nwell_raw).most_common(1)[0][0])

                return results
        except Exception as e:
            print(f"Warning: Could not parse {self.DIAG_LOG_FILENAME} from {diag_zip_path}: {e}")
            return None

    def init_metadata(self):
        """Initialize all metadata from Incucyte structure"""
        self._scan_timepoints()  # Must be first to set plate_id
        self._get_experiment_metadata()  # Uses plate_id in name
        self._get_sample_image_info()
        self._get_well_info()
        self._get_channel_info()
        self._get_image_info()

        # Initialize properties like TiffSource does
        self.name = self.metadata.get("Name", "Incucyte_Experiment")
        self.dim_order = self.metadata.get("dim_order", "tczyx")
        self.dtype = self.metadata.get("dtype", np.uint16)
        self.pixel_size = self._get_pixel_size_dict()
        self.channels = self._format_channels_for_interface()
        self.is_plate = len(self.metadata.get("wells", {})) > 0
        self.wells = list(self.metadata.get("wells", {}).keys())
        self.rows = self.metadata.get("well_info", {}).get("rows", [])
        self.columns = self.metadata.get("well_info", {}).get("columns", [])

        nt = len(self.metadata["time_points"])
        nc = self.metadata["num_channels"]
        nz = 1  # Incucyte is typically 2D
        self.shape = nt, nc, nz, self.height, self.width
        self.shapes = [(nt, nc, nz, height, width) for height, width in zip(self.heights, self.widths)]
        self.scales = [np.mean([width / self.width, height / self.height]) for width, height in zip(self.widths, self.heights)]

        return self.metadata

    def _get_experiment_metadata(self):
        """Extract experiment metadata from folder structure"""
        experiment_name = self.base_path.name

        # Add plate ID to name (plate_id is set by _scan_timepoints)
        if self.plate_id:
            experiment_name = f"{experiment_name}_plate{self.plate_id}"

        self.metadata.update(
            {
                "Name": experiment_name,
                "Creator": "Incucyte",
                "DateCreated": datetime.now(),
                "dim_order": "tczyx",
            }
        )

    def _scan_timepoints(self):
        """Scan the Incucyte directory structure for timepoints"""
        timepoints = []
        wells = set()
        fields = set()
        channels = set()
        found_plate_ids = set()

        print(f"Scanning directory: {self.scan_data_path}")

        if not self.scan_data_path.exists():
            raise ValueError(
                f"Scan data path not found: {self.scan_data_path}"
            )

        # Navigate through year/month directories (YYMM)
        for year_month in self.scan_data_path.iterdir():
            if not year_month.is_dir():
                continue
            # Navigate through day directories (DD)
            for day in year_month.iterdir():
                if not day.is_dir():
                    continue
                # Navigate through time directories (HHMM)
                for time_dir in day.iterdir():
                    if not time_dir.is_dir():
                        continue
                    # Navigate through plate ID directories (XXXX)
                    for plate_dir in time_dir.iterdir():
                        if not plate_dir.is_dir():
                            continue

                        current_plate_id = plate_dir.name
                        found_plate_ids.add(current_plate_id)

                        # Filter by plate_id if specified
                        if self.plate_id is not None:
                            if current_plate_id != self.plate_id:
                                continue

                        timepoint_path = plate_dir
                        timestamp = (
                            f"{year_month.name}_{day.name}_{time_dir.name}"
                        )

                        # Parse timestamp to datetime
                        try:
                            dt = datetime.strptime(timestamp, "%y%m_%d_%H%M")
                            if dt.year < 2000:
                                dt = dt.replace(year=dt.year + 2000)
                        except ValueError:
                            dt = None

                        timepoint_info = {
                            "path": timepoint_path,
                            "timestamp": timestamp,
                            "datetime": dt,
                            "index": len(timepoints),
                            "plate_id": current_plate_id,
                        }
                        timepoints.append(timepoint_info)

                        # Scan TIFF files in this timepoint
                        for tiff_file in timepoint_path.glob("*.tif"):
                            well, field, channel = self._parse_filename(tiff_file.name)
                            if well and field is not None and channel:
                                wells.add(well)
                                fields.add(field)
                                channels.add(channel)

        # Handle plate selection
        if self.plate_id is None:
            # Auto-select plate
            if len(found_plate_ids) == 0:
                raise ValueError("No plates found in the archive")
            elif len(found_plate_ids) == 1:
                # Single plate - use it automatically
                self.plate_id = list(found_plate_ids)[0]
            else:
                # Multiple plates - use first with warning
                plate_list = ", ".join(sorted(found_plate_ids))
                print(
                    f"Warning: Multiple plates found ({plate_list}). "
                    f"Using first plate: {sorted(found_plate_ids)[0]}"
                )
                print(
                    "To process a specific plate, use: "
                    "IncucyteSource(uri, plate_id='XXX')"
                )
                print(
                    "To process all plates, call get_available_plates() "
                    "and create separate sources"
                )
                self.plate_id = sorted(found_plate_ids)[0]

            # Filter timepoints to selected plate
            timepoints = [
                tp for tp in timepoints if tp["plate_id"] == self.plate_id
            ]
        else:
            # Validate specified plate_id
            if self.plate_id not in found_plate_ids:
                raise ValueError(
                    f"Plate ID '{self.plate_id}' not found. "
                    f"Available plates: {', '.join(sorted(found_plate_ids))}"
                )
            # Filter timepoints to specified plate
            timepoints = [
                tp for tp in timepoints if tp["plate_id"] == self.plate_id
            ]

        # Store found plate IDs in metadata
        self.metadata["available_plates"] = sorted(found_plate_ids)
        self.metadata["selected_plate"] = self.plate_id

        # Sort timepoints by datetime if available, otherwise by timestamp
        timepoints.sort(
            key=lambda x: x["datetime"] if x["datetime"] else x["timestamp"]
        )

        # Update indices after sorting
        for i, tp in enumerate(timepoints):
            tp["index"] = i

        self.metadata.update(
            {
                "timepoints": timepoints,
                "time_points": [tp["index"] for tp in timepoints],
                "wells_raw": sorted(wells),
                "fields_raw": sorted(fields),
                "channels_raw": sorted(channels),
            }
        )

        plate_info = (
            f" (plate: {self.plate_id})" if self.plate_id else ""
        )
        print(
            f"Found{plate_info}: {len(timepoints)} timepoints, "
            f"{len(wells)} wells, {len(fields)} fields, "
            f"{len(channels)} channels"
        )

    def _parse_filename(self, filename):
        """
        Parse Incucyte filename format: WELL-FIELD-CHANNEL.tif
        Examples: A1-1-C1.tif, B2-1-Ph.tif
        Returns: (well, field, channel)
        """
        pattern = r"([A-Z]\d+)-(\d+)-(.+)\.tif"
        match = re.match(pattern, filename)
        if match:
            well = match.group(1)
            field = int(match.group(2)) - 1  # Convert to 0-based indexing
            channel = match.group(3)
            return well, field, channel
        return None, None, None

    def _get_well_info(self):
        """Process well information and determine plate layout"""
        wells_raw = self.metadata["wells_raw"]

        if not wells_raw:
            raise ValueError("No wells found in data")

        # Parse well positions
        rows = set()
        cols = set()
        wells_dict = {}

        for well_index, well_name in enumerate(wells_raw):
            row, col = split_well_name(well_name, col_as_int=True)

            rows.add(row)
            cols.add(col)

            wells_dict[well_name] = {
                "Name": well_name,
                "row": ord(row) - ord("A"),
                "column": col - 1,
                "ZoneIndex": well_index,
            }

        nwell_plate = self.sample_image_info.get("nwell_plate")
        if nwell_plate:
            rows, cols = get_rows_cols_plate(nwell_plate)
        else:
            rows = sorted(rows)
            cols = [str(col) for col in sorted(cols)]

        # Get image dimensions from first available image
        sample_image_info = self.sample_image_info

        well_info = {
            "rows": rows,
            "columns": cols,
            "SensorSizeXPixels": sample_image_info["width"],
            "SensorSizeYPixels": sample_image_info["height"],
            "SitesX": 1,
            "SitesY": 1,
            "num_sites": len(self.metadata["fields_raw"]),
            "fields": [str(f) for f in self.metadata["fields_raw"]],
            "PixelSizeUm": sample_image_info["pixel_x"],
            "SensorBitness": sample_image_info["bits"],
            "max_sizex_um": sample_image_info["width"] * sample_image_info["pixel_x"],
            "max_sizey_um": sample_image_info["height"] * sample_image_info["pixel_y"],
        }

        # Add optional imaging metadata if available
        if "magnification" in sample_image_info:
            well_info["Magnification"] = sample_image_info["magnification"]
        if "exposure_times_ms" in sample_image_info:
            well_info["ExposureTimes_ms"] = sample_image_info["exposure_times_ms"]

        self.metadata.update({"wells": wells_dict, "well_info": well_info})

    def _get_sample_image_info(self):
        """Get image dimensions and bit depth from first available TIFF.
        Attempts to get accurate pixel size from Diag.log if available."""

        pixel_size_from_diag = None
        magnification = None
        exposure_time = None
        nwell_plate = None

        if self.plate_id:
            # Try to get calibrated pixel size from Diag.log
            diag_metadata = self._find_and_parse_diag_log()
            if diag_metadata and 'experiments' in diag_metadata:
                exp_info = diag_metadata['experiments'].get(self.plate_id)
                if exp_info:
                    pixel_size_from_diag = exp_info.get('pixel_size_um')
                    magnification = exp_info.get('magnification')
                    exposure_times = exp_info.get('exposure_times_ms')
                    # Use the exposure times list if available
                    exposure_time = exposure_times
                    if pixel_size_from_diag:
                        print(f"Found calibrated pixel size from {self.DIAG_LOG_FILENAME}: "
                              f"{pixel_size_from_diag} µm/pixel "
                              f"(Magnification: {magnification})")
                nwell_plate = diag_metadata.get('nwell_plate')

        for timepoint in self.metadata["timepoints"]:
            for tiff_file in timepoint["path"].glob("*.tif"):
                try:
                    # Get actual image dimensions from the file
                    with tifffile.TiffFile(str(tiff_file)) as tif:
                        page = tif.pages.first
                        # page.sizes keys differ between tifffile versions (width/height vs X/Y)
                        width = page.imagewidth
                        height = page.imagelength
                        dtype = page.dtype
                        bits = dtype.itemsize * 8
                        if tif.series:
                            series_page = tif.series[0]
                            if hasattr(series_page, 'levels'):
                                level_pages = series_page.levels
                                widths = [level_page.shape[level_page.axes.index('X')] for level_page in level_pages]
                                heights = [level_page.shape[level_page.axes.index('Y')] for level_page in level_pages]

                    # Use calibrated pixel size from Diag.log if available
                    if pixel_size_from_diag:
                        pixel_x = pixel_size_from_diag
                        pixel_y = pixel_size_from_diag
                    else:
                        # Fallback to TIFF metadata
                        temp_tiff_source = TiffSource(str(tiff_file))
                        temp_tiff_source.init_metadata()
                        pixel_size = temp_tiff_source.get_pixel_size_um()
                        temp_tiff_source.close()
                        pixel_x = pixel_size.get("x")
                        pixel_y = pixel_size.get("y")

                    self.sample_image_info = {
                        "width": width,
                        "height": height,
                        "bits": bits,
                        "dtype": dtype,
                        "pixel_x": pixel_x,
                        "pixel_y": pixel_y,
                    }
                    self.width = width
                    self.height = height
                    self.widths = widths
                    self.heights = heights

                    # Add optional metadata if available
                    if magnification:
                        self.sample_image_info["magnification"] = magnification
                    if exposure_time:
                        self.sample_image_info["exposure_times_ms"] = exposure_time
                    if nwell_plate:
                        self.sample_image_info["nwell_plate"] = nwell_plate

                    return

                except Exception as e:
                    print(f"Could not read sample image {tiff_file}: {e}")
                    continue

        # If no valid TIFF files found
        raise ValueError(
            f"No valid TIFF files found in experiment directory: "
            f"{self.scan_data_path}"
        )

    def _get_channel_info(self):
        """Process channel information"""
        channels_raw = self.metadata["channels_raw"]
        channels = []

        channel_mapping = {
            "C1": {"label": "Green", "color": "00FF00"},
            "C2": {"label": "Red", "color": "FF0000"},
            "Ph": {"label": "Phase_Contrast", "color": "FFFFFF"},
            "P": {"label": "Phase_Contrast", "color": "FFFFFF"},
        }

        for i, channel_code in enumerate(channels_raw):
            channel_info = channel_mapping.get(
                channel_code, {"label": channel_code, "color": "FFFFFF"}
            )

            channels.append(
                {
                    "ChannelNumber": i,
                    "Dye": channel_info["label"],
                    "Color": f"#{channel_info['color']}",
                    "Emission": None,
                    "Excitation": None,
                    "code": channel_code,
                }
            )

        self.metadata.update({"channels": channels, "num_channels": len(channels)})

    def _get_image_info(self):
        """Get image-related metadata"""
        well_info = self.metadata["well_info"]
        max_data_size = (
            well_info["SensorSizeXPixels"]
            * well_info["SensorSizeYPixels"]
            * len(self.metadata["wells"])
            * well_info["num_sites"]
            * self.metadata["num_channels"]
            * len(self.metadata["time_points"])
            * (self.sample_image_info["bits"] // 8)
        )

        self.metadata.update(
            {
                "bits_per_pixel": self.sample_image_info["bits"],
                "dtype": self.sample_image_info["dtype"],
                "max_data_size": max_data_size,
            }
        )

    def _get_pixel_size_dict(self):
        """Get pixel size in TiffSource format"""
        well_info = self.metadata.get("well_info", {})
        pixel_size = well_info.get("PixelSizeUm", 1.0)
        return {"x": pixel_size, "y": pixel_size}

    def _format_channels_for_interface(self):
        """Format channels for interface compatibility"""
        channels = self.metadata.get("channels", [])
        return [
            {"label": channel["Dye"], "color": hexrgb_to_rgba(channel["Color"].lstrip("#"))} for channel in channels
        ]

    def _load_image_data(self, well_id, field_id, channel_id, timepoint_id, level=0):
        """Load specific image data"""
        cache_key = (well_id, field_id, channel_id, timepoint_id, level)
        if cache_key in self._file_cache:
            return self._file_cache[cache_key]

        data = None

        # Find the file for this combination
        timepoint_info = self.metadata["timepoints"][timepoint_id]
        channel_code = self.metadata["channels_raw"][channel_id]

        filename = f"{well_id}-{field_id + 1}-{channel_code}.tif"
        file_path = timepoint_info["path"] / filename

        message = ""
        # Check if file exists
        if not file_path.exists():
            if self.fill_missing_images:
                message = f"Warning: Missing image file {file_path}, filled with black image"
            else:
                raise FileNotFoundError(f"Image file not found: {file_path}")

        try:
            # Let TiffFile handle the file reading errors naturally
            with tifffile.TiffFile(str(file_path)) as tif:
                data = tif.asarray(level=level)
        except Exception as e:
            if self.fill_missing_images:
                message = f"Warning: Could not read image file {file_path}: {e}, filled with black image"
            else:
                raise e

        if data is None and self.fill_missing_images:
            # Create a black image with the same dimensions as other images
            data = np.zeros((self.sample_image_info["height"], self.sample_image_info["width"]),
                            dtype=self.sample_image_info["dtype"])
            print(message)

        if self._file_caching:
            self._file_cache[cache_key] = data
        return data

    # ImageSource interface methods
    def is_screen(self):
        return self.is_plate

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        """Get data for a specific well and field"""
        well_id = strip_leading_zeros(well_id)

        if well_id not in self.metadata["wells"]:
            raise ValueError(
                f"Invalid Well: {well_id}. Available: {list(self.metadata['wells'].keys())}"
            )

        field_id = int(field_id)
        if field_id not in self.metadata["fields_raw"]:
            raise ValueError(
                f"Invalid Field: {field_id}. Available: {self.metadata['fields_raw']}"
            )

        # Build 5D array: (t, c, z, y, x)
        nt = len(self.metadata["time_points"])
        nc = self.metadata["num_channels"]

        data = np.zeros(self.shape, dtype=self.sample_image_info["dtype"])

        for t in range(nt):
            for c in range(nc):
                image_data = self._load_image_data(well_id, field_id, c, t, level=level)
                # Handle different image shapes
                if image_data.ndim > 2:
                    # assume data [z, y, x] - TODO: for 3D support _load_image_data() needs to handle z properly
                    data[t, c, :, :, :] = image_data
                else:
                    data[t, c, 0, :, :] = image_data

        return redimension_data(data, self.dim_order, dim_order)

    def get_shape(self):
        return self.shape

    def get_scales(self):
        return self.scales

    def get_name(self):
        return self.name

    def get_dim_order(self):
        return self.dim_order

    def get_dtype(self):
        return self.dtype

    def get_pixel_size_um(self):
        return self.pixel_size

    def get_position_um(self, well_id=None):
        well = self.metadata["wells"].get(well_id, {})
        well_info = self.metadata["well_info"]
        x = well.get("CoordX", 0) * well_info.get("max_sizex_um", 0)
        y = well.get("CoordY", 0) * well_info.get("max_sizey_um", 0)
        return {"x": x, "y": y}

    def get_channels(self):
        return self.channels

    def get_nchannels(self):
        return max(self.metadata.get("num_channels", 1), 1)

    def get_rows(self):
        return self.rows

    def get_columns(self):
        return self.columns

    def get_wells(self):
        return self.wells

    def get_time_points(self):
        return self.metadata.get("time_points", [])

    def get_fields(self):
        return self.metadata.get("well_info", {}).get("fields", [])

    def get_well_coords_um(self, well_id):
        """Get well coordinates (placeholder - Incucyte doesn't typically have stage coordinates)"""
        return {"x": 0.0, "y": 0.0}

    def get_acquisitions(self):
        """Return acquisition information based on timepoints"""
        acquisitions = []
        for i, tp in enumerate(self.metadata.get("timepoints", [])):
            acq = {
                "id": i,
                "name": f"Timepoint_{tp['timestamp']}",
                "description": f"Incucyte acquisition at {tp['timestamp']}",
                "date_created": tp["datetime"].isoformat()
                if tp["datetime"]
                else tp["timestamp"],
                "date_modified": tp["datetime"].isoformat()
                if tp["datetime"]
                else tp["timestamp"],
            }
            acquisitions.append(acq)
        return acquisitions

    def get_acquisition_datetime(self):
        timepoints = self.metadata.get("timepoints", [])
        if timepoints and timepoints[0]["datetime"]:
            return timepoints[0]["datetime"]
        return None

    def get_acquisition_end_datetime(self):
        timepoints = self.metadata.get("timepoints", [])
        if timepoints and timepoints[-1]["datetime"]:
            return timepoints[-1]["datetime"]
        return None

    def get_significant_bits(self):
        return self.metadata.get("bits_per_pixel")

    def get_total_data_size(self):
        return self.metadata.get("max_data_size", 0)

    def print_well_matrix(self):
        """Print a visual representation of the plate layout"""
        s = ""
        well_info = self.metadata.get("well_info", {})
        rows = well_info.get("rows", [])
        cols = [int(c) for c in well_info.get("columns", [])]
        used_wells = set(self.metadata.get("wells", {}).keys())

        # Header with column numbers
        header = "   " + "  ".join(f"{col:2d}" for col in cols)
        s += header + "\n"

        # Each row
        for row_letter in rows:
            row_line = f"{row_letter}  "
            for col_num in cols:
                well_id = f"{row_letter}{col_num}"
                row_line += " + " if well_id in used_wells else "   "
            s += row_line + "\n"

        return s

    def print_timepoint_well_matrix(self):
        """Print timepoint vs well matrix"""
        s = ""
        timepoints = self.metadata.get("timepoints", [])
        wells = list(self.metadata.get("wells", {}).keys())

        # Header
        header = "Timepoint   " + "  ".join(f"{well:>3}" for well in wells)
        s += header + "\n"

        # Check which wells have data at each timepoint
        for tp in timepoints:
            line = f"{tp['timestamp']:>9}   "
            for well in wells:
                # Check if any files exist for this well at this timepoint
                has_data = any(
                    (tp["path"] / f"{well}-{field + 1}-{channel}.tif").exists()
                    for field in self.metadata.get("fields_raw", [])
                    for channel in self.metadata.get("channels_raw", [])
                )
                line += " + " if has_data else "   "
            s += line + "\n"

        return s

    def is_rgb(self):
        """
        Check if the source is a RGB(A) image.
        Incucyte data stores channels separately, not as RGB.
        """
        return False

    def close(self):
        """Clean up resources"""
        self._file_cache.clear()

DIAG_LOG_FILENAME = 'Diag.log' class-attribute instance-attribute

DIAG_ZIP_FILENAME = 'Diag.zip' class-attribute instance-attribute

base_path = Path(self.uri) instance-attribute

fill_missing_images = True instance-attribute

plate_id = plate_id instance-attribute

scan_data_path = self.base_path / 'EssenFiles' / 'ScanData' instance-attribute

__init__(uri, metadata={}, plate_id=None)

Initialize IncucyteSource.

Parameters:

Name Type Description Default
uri str

Path to the Incucyte archive folder

required
metadata dict

Optional metadata dictionary

{}
plate_id str

Specific plate ID to process (e.g., '700', '701'). If None, will use the first available plate or all if only one exists.

None
Source code in src/biomero_converter/IncucyteSource.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def __init__(self, uri, metadata={}, plate_id=None):
    """
    Initialize IncucyteSource.

    Args:
        uri (str): Path to the Incucyte archive folder
        metadata (dict): Optional metadata dictionary
        plate_id (str, optional): Specific plate ID to process (e.g., '700', '701').
                                 If None, will use the first available plate or all 
                                 if only one exists.
    """
    super().__init__(uri, metadata)
    self.plate_id = plate_id
    self.base_path = Path(self.uri)
    self.scan_data_path = self.base_path / "EssenFiles" / "ScanData"
    self._file_cache = {}
    self._file_caching = False
    # Default to True for filling missing images
    self.fill_missing_images = True

close()

Clean up resources

Source code in src/biomero_converter/IncucyteSource.py
812
813
814
def close(self):
    """Clean up resources"""
    self._file_cache.clear()

enable_file_caching(file_caching=True)

Enable or disable file caching for image data.

Parameters:

Name Type Description Default
file_caching bool

If True, enable file caching; if False, disable it.

True
Source code in src/biomero_converter/IncucyteSource.py
89
90
91
92
93
94
95
96
97
98
def enable_file_caching(self, file_caching=True):
    """
    Enable or disable file caching for image data.

    Args:
        file_caching (bool): If True, enable file caching; if False, disable it.
    """
    self._file_caching = file_caching
    if not file_caching:
        self._file_cache.clear()

get_acquisition_datetime()

Source code in src/biomero_converter/IncucyteSource.py
740
741
742
743
744
def get_acquisition_datetime(self):
    timepoints = self.metadata.get("timepoints", [])
    if timepoints and timepoints[0]["datetime"]:
        return timepoints[0]["datetime"]
    return None

get_acquisition_end_datetime()

Source code in src/biomero_converter/IncucyteSource.py
746
747
748
749
750
def get_acquisition_end_datetime(self):
    timepoints = self.metadata.get("timepoints", [])
    if timepoints and timepoints[-1]["datetime"]:
        return timepoints[-1]["datetime"]
    return None

get_acquisitions()

Return acquisition information based on timepoints

Source code in src/biomero_converter/IncucyteSource.py
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
def get_acquisitions(self):
    """Return acquisition information based on timepoints"""
    acquisitions = []
    for i, tp in enumerate(self.metadata.get("timepoints", [])):
        acq = {
            "id": i,
            "name": f"Timepoint_{tp['timestamp']}",
            "description": f"Incucyte acquisition at {tp['timestamp']}",
            "date_created": tp["datetime"].isoformat()
            if tp["datetime"]
            else tp["timestamp"],
            "date_modified": tp["datetime"].isoformat()
            if tp["datetime"]
            else tp["timestamp"],
        }
        acquisitions.append(acq)
    return acquisitions

get_available_plates(uri) staticmethod

Discover all available plate IDs in an Incucyte archive.

Parameters:

Name Type Description Default
uri str

Path to the Incucyte archive folder

required

Returns:

Name Type Description
list

List of plate IDs (strings) found in the archive

Source code in src/biomero_converter/IncucyteSource.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@staticmethod
def get_available_plates(uri):
    """
    Discover all available plate IDs in an Incucyte archive.

    Args:
        uri (str): Path to the Incucyte archive folder

    Returns:
        list: List of plate IDs (strings) found in the archive
    """
    base_path = Path(uri)
    scan_data_path = base_path / "EssenFiles" / "ScanData"

    if not scan_data_path.exists():
        raise ValueError(f"Scan data path not found: {scan_data_path}")

    plate_ids = set()

    # Navigate through the directory structure to find all plate IDs
    for year_month in scan_data_path.iterdir():
        if not year_month.is_dir():
            continue
        for day in year_month.iterdir():
            if not day.is_dir():
                continue
            for time_dir in day.iterdir():
                if not time_dir.is_dir():
                    continue
                for plate_dir in time_dir.iterdir():
                    if plate_dir.is_dir():
                        plate_ids.add(plate_dir.name)

    return sorted(list(plate_ids))

get_channels()

Source code in src/biomero_converter/IncucyteSource.py
697
698
def get_channels(self):
    return self.channels

get_columns()

Source code in src/biomero_converter/IncucyteSource.py
706
707
def get_columns(self):
    return self.columns

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Get data for a specific well and field

Source code in src/biomero_converter/IncucyteSource.py
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    """Get data for a specific well and field"""
    well_id = strip_leading_zeros(well_id)

    if well_id not in self.metadata["wells"]:
        raise ValueError(
            f"Invalid Well: {well_id}. Available: {list(self.metadata['wells'].keys())}"
        )

    field_id = int(field_id)
    if field_id not in self.metadata["fields_raw"]:
        raise ValueError(
            f"Invalid Field: {field_id}. Available: {self.metadata['fields_raw']}"
        )

    # Build 5D array: (t, c, z, y, x)
    nt = len(self.metadata["time_points"])
    nc = self.metadata["num_channels"]

    data = np.zeros(self.shape, dtype=self.sample_image_info["dtype"])

    for t in range(nt):
        for c in range(nc):
            image_data = self._load_image_data(well_id, field_id, c, t, level=level)
            # Handle different image shapes
            if image_data.ndim > 2:
                # assume data [z, y, x] - TODO: for 3D support _load_image_data() needs to handle z properly
                data[t, c, :, :, :] = image_data
            else:
                data[t, c, 0, :, :] = image_data

    return redimension_data(data, self.dim_order, dim_order)

get_dim_order()

Source code in src/biomero_converter/IncucyteSource.py
681
682
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/IncucyteSource.py
684
685
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/IncucyteSource.py
715
716
def get_fields(self):
    return self.metadata.get("well_info", {}).get("fields", [])

get_name()

Source code in src/biomero_converter/IncucyteSource.py
678
679
def get_name(self):
    return self.name

get_nchannels()

Source code in src/biomero_converter/IncucyteSource.py
700
701
def get_nchannels(self):
    return max(self.metadata.get("num_channels", 1), 1)

get_pixel_size_um()

Source code in src/biomero_converter/IncucyteSource.py
687
688
def get_pixel_size_um(self):
    return self.pixel_size

get_position_um(well_id=None)

Source code in src/biomero_converter/IncucyteSource.py
690
691
692
693
694
695
def get_position_um(self, well_id=None):
    well = self.metadata["wells"].get(well_id, {})
    well_info = self.metadata["well_info"]
    x = well.get("CoordX", 0) * well_info.get("max_sizex_um", 0)
    y = well.get("CoordY", 0) * well_info.get("max_sizey_um", 0)
    return {"x": x, "y": y}

get_rows()

Source code in src/biomero_converter/IncucyteSource.py
703
704
def get_rows(self):
    return self.rows

get_scales()

Source code in src/biomero_converter/IncucyteSource.py
675
676
def get_scales(self):
    return self.scales

get_shape()

Source code in src/biomero_converter/IncucyteSource.py
672
673
def get_shape(self):
    return self.shape

get_significant_bits()

Source code in src/biomero_converter/IncucyteSource.py
752
753
def get_significant_bits(self):
    return self.metadata.get("bits_per_pixel")

get_time_points()

Source code in src/biomero_converter/IncucyteSource.py
712
713
def get_time_points(self):
    return self.metadata.get("time_points", [])

get_total_data_size()

Source code in src/biomero_converter/IncucyteSource.py
755
756
def get_total_data_size(self):
    return self.metadata.get("max_data_size", 0)

get_well_coords_um(well_id)

Get well coordinates (placeholder - Incucyte doesn't typically have stage coordinates)

Source code in src/biomero_converter/IncucyteSource.py
718
719
720
def get_well_coords_um(self, well_id):
    """Get well coordinates (placeholder - Incucyte doesn't typically have stage coordinates)"""
    return {"x": 0.0, "y": 0.0}

get_wells()

Source code in src/biomero_converter/IncucyteSource.py
709
710
def get_wells(self):
    return self.wells

init_metadata()

Initialize all metadata from Incucyte structure

Source code in src/biomero_converter/IncucyteSource.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def init_metadata(self):
    """Initialize all metadata from Incucyte structure"""
    self._scan_timepoints()  # Must be first to set plate_id
    self._get_experiment_metadata()  # Uses plate_id in name
    self._get_sample_image_info()
    self._get_well_info()
    self._get_channel_info()
    self._get_image_info()

    # Initialize properties like TiffSource does
    self.name = self.metadata.get("Name", "Incucyte_Experiment")
    self.dim_order = self.metadata.get("dim_order", "tczyx")
    self.dtype = self.metadata.get("dtype", np.uint16)
    self.pixel_size = self._get_pixel_size_dict()
    self.channels = self._format_channels_for_interface()
    self.is_plate = len(self.metadata.get("wells", {})) > 0
    self.wells = list(self.metadata.get("wells", {}).keys())
    self.rows = self.metadata.get("well_info", {}).get("rows", [])
    self.columns = self.metadata.get("well_info", {}).get("columns", [])

    nt = len(self.metadata["time_points"])
    nc = self.metadata["num_channels"]
    nz = 1  # Incucyte is typically 2D
    self.shape = nt, nc, nz, self.height, self.width
    self.shapes = [(nt, nc, nz, height, width) for height, width in zip(self.heights, self.widths)]
    self.scales = [np.mean([width / self.width, height / self.height]) for width, height in zip(self.widths, self.heights)]

    return self.metadata

is_rgb()

Check if the source is a RGB(A) image. Incucyte data stores channels separately, not as RGB.

Source code in src/biomero_converter/IncucyteSource.py
805
806
807
808
809
810
def is_rgb(self):
    """
    Check if the source is a RGB(A) image.
    Incucyte data stores channels separately, not as RGB.
    """
    return False

is_screen()

Source code in src/biomero_converter/IncucyteSource.py
636
637
def is_screen(self):
    return self.is_plate

print_timepoint_well_matrix()

Print timepoint vs well matrix

Source code in src/biomero_converter/IncucyteSource.py
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
def print_timepoint_well_matrix(self):
    """Print timepoint vs well matrix"""
    s = ""
    timepoints = self.metadata.get("timepoints", [])
    wells = list(self.metadata.get("wells", {}).keys())

    # Header
    header = "Timepoint   " + "  ".join(f"{well:>3}" for well in wells)
    s += header + "\n"

    # Check which wells have data at each timepoint
    for tp in timepoints:
        line = f"{tp['timestamp']:>9}   "
        for well in wells:
            # Check if any files exist for this well at this timepoint
            has_data = any(
                (tp["path"] / f"{well}-{field + 1}-{channel}.tif").exists()
                for field in self.metadata.get("fields_raw", [])
                for channel in self.metadata.get("channels_raw", [])
            )
            line += " + " if has_data else "   "
        s += line + "\n"

    return s

print_well_matrix()

Print a visual representation of the plate layout

Source code in src/biomero_converter/IncucyteSource.py
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
def print_well_matrix(self):
    """Print a visual representation of the plate layout"""
    s = ""
    well_info = self.metadata.get("well_info", {})
    rows = well_info.get("rows", [])
    cols = [int(c) for c in well_info.get("columns", [])]
    used_wells = set(self.metadata.get("wells", {}).keys())

    # Header with column numbers
    header = "   " + "  ".join(f"{col:2d}" for col in cols)
    s += header + "\n"

    # Each row
    for row_letter in rows:
        row_line = f"{row_letter}  "
        for col_num in cols:
            well_id = f"{row_letter}{col_num}"
            row_line += " + " if well_id in used_wells else "   "
        s += row_line + "\n"

    return s

LeicaSource

LAYOUT_KEYS = ('BytesInc', 'BitInc') module-attribute

LUT_COLORS = {'red': [1, 0, 0, 1], 'green': [0, 1, 0, 1], 'blue': [0, 0, 1, 1], 'cyan': [0, 1, 1, 1], 'magenta': [1, 0, 1, 1], 'yellow': [1, 1, 0, 1], 'gray': [1, 1, 1, 1], 'grey': [1, 1, 1, 1]} module-attribute

LeicaSource

Bases: ImageSource

Loads image and metadata from Leica LIF, LOF or XLEF files. A file can contain multiple images; a single image is selected by UUID or index (default: first image). Tile scans (mosaic) are stitched using the tile field positions; a tile scan with gaps between its tiles holds separate positions instead, one selected by position index.

Source code in src/biomero_converter/LeicaSource.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
class LeicaSource(ImageSource):
    """
    Loads image and metadata from Leica LIF, LOF or XLEF files.
    A file can contain multiple images; a single image is selected by UUID or index (default: first image).
    Tile scans (mosaic) are stitched using the tile field positions; a tile scan with gaps between its tiles holds
    separate positions instead, one selected by position index.
    """
    def __init__(self, uri, metadata={}, image_uuid=None, image_index=None, position=None, **kwargs):
        """
        Initialize LeicaSource.

        Args:
            uri (str): Path to the Leica file.
            metadata (dict): Optional metadata dictionary.
            image_uuid (str, optional): UUID of the image to read.
            image_index (int, optional): Index of the image to read (e.g. for older files without UUIDs).
            position (int, optional): Index of the position to read, for a tile scan with gaps between its tiles.
        """
        super().__init__(uri, metadata)
        self.position_index = int(position) if position is not None else None
        self.lif = LifFile(uri)
        if image_uuid:
            images = [image for image in self.lif.images if image.uuid == image_uuid]
            if not images:
                raise ValueError(f'Image UUID {image_uuid} not found in {uri}')
            self.image = images[0]
        elif image_index is not None:
            self.image = self.lif.images[int(image_index)]
        else:
            if not self.lif.images:
                raise ValueError(f'No images found in {uri}')
            self.image = self.lif.images[0]

    @staticmethod
    def get_image_count(uri):
        """
        Get the number of images in a Leica file.

        Args:
            uri (str): Path to the Leica file.

        Returns:
            int: Number of images.
        """
        with LifFile(uri) as lif:
            return len(lif.images)

    def init_metadata(self):
        image = self.image
        self.metadata = image.attrs
        sizes = image.sizes
        coords = image.coords

        # map source dimensions to tczyx; RGB samples (S) as channels; other dimensions use index 0
        self.source_dims = image.dims
        self.is_rgb_channels = 'S' in sizes
        unsupported_dims = [dim for dim in self.source_dims if dim not in 'TCZYXSM']
        if unsupported_dims:
            logging.warning(f'Leica image {image.name}: unsupported dimension(s) {unsupported_dims}, using first index')

        self.dim_order = 'tczyx'
        self.tile_size = sizes['Y'], sizes['X']
        self.tile_flip = False, False, False
        self.tile_index = 0
        self.tile_position = None
        self.tile_grid = self._get_tile_grid(sizes.get('M', 1))
        if self.tile_grid is not None:
            ny, nx = len(self.tile_grid), len(self.tile_grid[0])
            height = (ny - 1) * self.tile_step[0] + self.tile_size[0]
            width = (nx - 1) * self.tile_step[1] + self.tile_size[1]
        else:
            height, width = self.tile_size
        nchannels = sizes.get('C', 1) * sizes.get('S', 1)
        self.shape = (sizes.get('T', 1), nchannels, sizes.get('Z', 1), height, width)
        self.shapes = [self.shape]
        self.scales = [1]
        self.nchannels = nchannels
        self.dtype = image.dtype

        self.pixel_size = {}
        self.position = {}
        for dim in 'xyz':
            coord = coords.get(dim.upper())
            if coord is not None and len(coord) > 0:
                self.position[dim] = float(coord[0]) * 1e6
                if len(coord) > 1:
                    self.pixel_size[dim] = abs(float(coord[1] - coord[0])) * 1e6
        if self.tile_position is not None:
            self.position = self.tile_position

        channel_elements = sorted(
            image.xml_element.findall('./Data/Image/ImageDescription/Channels/ChannelDescription'),
            key=lambda element: int(element.attrib.get('BytesInc', 0)))
        self.bits_per_pixel = max([int(element.attrib.get('Resolution', 0)) for element in channel_elements],
                                  default=self.dtype.itemsize * 8)
        self.channels = []
        if self.is_rgb_channels:
            for label, color in zip(['Red', 'Green', 'Blue'], [[1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]):
                self.channels.append({'label': label, 'color': color})
        else:
            labels = (sequential_channel_names(image.xml_element, nchannels)
                      or coords.get('C', [f'Ch{index}' for index in range(nchannels)]))
            # widefield channel names & emission, if matching channels
            widefield_infos = list(image.xml_element.iter('WideFieldChannelInfo'))
            if len(widefield_infos) != len(channel_elements):
                widefield_infos = [None] * len(channel_elements)
            for label, element, widefield_info in zip(labels, channel_elements, widefield_infos):
                channel = {'label': str(label)}
                color = LUT_COLORS.get(element.attrib.get('LUTName', '').lower())
                if color:
                    channel['color'] = color
                if widefield_info is not None:
                    if widefield_info.attrib.get('UserDefName'):
                        channel['label'] = widefield_info.attrib['UserDefName']
                    emission = float(widefield_info.attrib.get('EmissionWavelength', 0))
                    if emission > 0:
                        channel['emission_wavelength'] = emission
                        channel['emission_wavelength_unit'] = 'nm'
                self.channels.append(channel)

        timestamps = image.timestamps
        if timestamps is not None and len(timestamps) > 0:
            self.acquisition_datetime = timestamps[0].astype('datetime64[us]').item()
            self.acquisition_end_datetime = timestamps[-1].astype('datetime64[us]').item()
        else:
            self.acquisition_datetime = None
            self.acquisition_end_datetime = None

        self.acquisition_metadata = self._get_acquisition_metadata()
        filetitle = get_filetitle(self.uri)
        image_name = image.path.replace('/', '_')
        self.name = filetitle if image_name == filetitle else f'{filetitle}_{image_name}'
        if self.tile_position is not None:
            self.name += f'_pos{self.tile_index}'
        elif self.position_index is not None:
            logging.warning(f'Leica image {image.name}: not a tile scan with separate positions, position ignored')
        return self.metadata

    def _get_tile_grid(self, ntiles):
        """
        Create grid of mosaic tile indices from the tile scan field positions.

        Returns:
            list: Nested list [y][x] of mosaic (M) index (or None for empty positions), or None if not a tile scan.
        """
        tilescan = self.image.tilescan
        if ntiles <= 1:
            return None
        if tilescan is None:
            logging.warning(f'Leica image {self.image.name}: mosaic without tile scan info, using first tile')
            return None
        # tiles are flipped / transposed before placing on the grid
        self.tile_flip = tilescan.flip_y, tilescan.flip_x, tilescan.swap_xy
        if tilescan.swap_xy:
            self.tile_size = self.tile_size[::-1]

        # mosaic coordinates index into the tile scan (e.g. for cropped tile scans)
        tile_indices = self.image.coords.get('M', range(ntiles))
        if len(tile_indices) != ntiles or max(tile_indices) >= len(tilescan):
            tile_indices = range(ntiles)
        tiles = [tilescan[int(index)] for index in tile_indices]
        field_xs = [int(tile['field_x']) for tile in tiles]
        field_ys = [int(tile['field_y']) for tile in tiles]
        min_x, min_y = min(field_xs), min(field_ys)
        nx, ny = max(field_xs) - min_x + 1, max(field_ys) - min_y + 1

        # step size (overlap) from stage positions of adjacent tiles
        pixel_size_m = [abs(float(np.diff(self.image.coords[dim][:2])[0])) for dim in 'YX']
        tile_positions = {(field_y, field_x): tile for field_x, field_y, tile in zip(field_xs, field_ys, tiles)}
        step = list(self.tile_size)
        for axis, (offset, pos_key) in enumerate([((1, 0), 'pos_y'), ((0, 1), 'pos_x')]):
            deltas = [abs(float(tile_positions[neighbour][pos_key] - tile[pos_key]))
                      for (field_y, field_x), tile in tile_positions.items()
                      if (neighbour := (field_y + offset[0], field_x + offset[1])) in tile_positions]
            deltas = [delta for delta in deltas if delta > 0]
            if deltas and pixel_size_m[axis] > 0:
                step[axis] = int(round(min(deltas) / pixel_size_m[axis]))
        if step[0] > self.tile_size[0] or step[1] > self.tile_size[1]:
            # gaps between the tiles (negative overlap): separate positions, one selected by index
            self._select_position(tiles)
            return None
        self.tile_step = [min(max(step[axis], 1), self.tile_size[axis]) for axis in range(2)]

        grid = [[None] * nx for _ in range(ny)]
        for m_index, (field_x, field_y) in enumerate(zip(field_xs, field_ys)):
            grid[field_y - min_y][field_x - min_x] = m_index
        return grid

    def _get_acquisition_metadata(self):
        acquisition_metadata = {'manufacturer': 'Leica Microsystems'}
        hardware_setting = self.metadata.get('HardwareSetting', {})
        if not isinstance(hardware_setting, dict):
            hardware_setting = {}
        # keep hierarchy, without ATL prefix, and of the Block settings only the sequences of a sequential scan,
        # each holding what differs for one of them (its active detectors and laser lines); the others, such as
        # the sequential master, mostly repeat the main settings
        sequences = hardware_setting.get('LDM_Block_Sequential', {})
        sequences = sequences.get('LDM_Block_Sequential_List') if isinstance(sequences, dict) else None
        hardware_setting = {key: value for key, value in hardware_setting.items()
                            if key != 'Name' and 'Block' not in key}
        if sequences:
            hardware_setting['LDM_Block_Sequential'] = {'LDM_Block_Sequential_List': sequences}
        hardware_setting = remove_key_prefix(hardware_setting, 'ATL')
        # the microscope and objective are read from these settings by the imaging metadata converter
        if hardware_setting:
            acquisition_metadata['HardwareSetting'] = hardware_setting
        # LAS AF (as on an SP5) writes its settings as lists of records instead
        setting_list = self.metadata.get('HardwareSettingList', {})
        records = setting_list.get('HardwareSetting') if isinstance(setting_list, dict) else None
        if isinstance(records, dict) and records:
            acquisition_metadata['HardwareSettingList'] = {
                **key_setting_records(records),
                **{key: value for key, value in setting_list.items() if key not in ('HardwareSetting', 'Name')}}
        # the acquisition software, its user and the acquisition time, where it writes them (LMD7)
        custom_data = self.metadata.get('CustomData')
        if isinstance(custom_data, dict):
            custom_data = {key: value for key, value in custom_data.items() if key != 'Name'}
            if any(isinstance(value, dict) for value in custom_data.values()):
                acquisition_metadata['CustomData'] = custom_data
        return acquisition_metadata

    def get_source_metadata(self):
        """
        All of the image's metadata, for the imaging metadata converter, but its path on this disk and what
        only lays out the pixel data in the file (a channel's or dimension's byte and bit increments): its
        attachments whole (the hardware settings without their ATL prefix, LAS AF's records keyed by their
        names), the channel and dimension descriptions, and the time stamps of its frames.
        """
        metadata = {'manufacturer': 'Leica Microsystems'}
        metadata |= {key: value for key, value in self.metadata.items() if key != 'filepath'}
        if isinstance(metadata.get('HardwareSetting'), dict):
            metadata['HardwareSetting'] = remove_key_prefix(metadata['HardwareSetting'], 'ATL')
        setting_list = metadata.get('HardwareSettingList')
        records = setting_list.get('HardwareSetting') if isinstance(setting_list, dict) else None
        if isinstance(records, dict) and records:
            metadata['HardwareSettingList'] = {
                **key_setting_records(records),
                **{key: value for key, value in setting_list.items() if key != 'HardwareSetting'}}
        description = self.image.xml_element.find('./Data/Image/ImageDescription')
        if description is not None:
            metadata['ImageDescription'] = without_keys(
                xml2dict(ElementTree.tostring(description, encoding='unicode'))['ImageDescription'], LAYOUT_KEYS)
        timestamps = self.image.timestamps
        if timestamps is not None and len(timestamps) > 0:
            metadata['timestamps'] = [str(timestamp) for timestamp in timestamps]
        return metadata

    def _select_position(self, tiles):
        positions = [{dim: float(tile[f'pos_{dim}']) * 1e6 for dim in 'xyz' if f'pos_{dim}' in tile.dtype.names}
                     for tile in tiles]
        if self.position_index is None:
            listing = '; '.join(f'{index}: ' + ', '.join(f'{dim}={value / 1e3:.3f} mm' for dim, value in position.items())
                                for index, position in enumerate(positions))
            raise ValueError(f'Leica image {self.image.name}: tile scan with gaps between its tiles holds '
                             f'{len(positions)} separate positions, select one by position index ({listing})')
        if not 0 <= self.position_index < len(positions):
            raise ValueError(f'Leica image {self.image.name}: position {self.position_index} out of range '
                             f'(0-{len(positions) - 1})')
        self.tile_index = self.position_index
        self.tile_position = positions[self.position_index]

    def _get_source_data(self, as_dask=False):
        """
        Get image data in tczyx order (with mosaic tiles stitched).
        """
        data = self.image.asarray(out='memmap')
        dims = ''.join(self.source_dims)
        # select first index of unsupported dimensions
        data = data[tuple(0 if dim not in 'TCZYXSM' else slice(None) for dim in dims)]
        dims = ''.join(dim for dim in dims if dim in 'TCZYXSM')
        if as_dask:
            data = da.from_array(data, chunks=tuple(TILE_SIZE if dim in 'YX' else 1 for dim in dims))
        if 'S' in dims:
            # RGB samples as channels
            if 'C' in dims:
                # move samples next to channels and merge
                new_dims = dims.replace('S', '').replace('C', 'CS')
                data = redimension_data(data, dims.lower(), new_dims.lower())
                c_index = new_dims.index('C')
                data = data.reshape(data.shape[:c_index] + (-1,) + data.shape[c_index + 2:])
                dims = new_dims.replace('S', '')
            else:
                dims = dims.replace('S', 'C')
        order = 'm' + self.dim_order if 'M' in dims else self.dim_order
        data = redimension_data(data, dims.lower(), order)
        if 'M' in dims:
            data = self._stitch_tiles(data, as_dask=as_dask)
        return data

    def _stitch_tiles(self, data, as_dask=False):
        """
        Stitch mosaic tiles (first axis) along the last two (yx) axes.
        """
        lib = da if as_dask else np
        if self.tile_grid is None:
            return self._transform_tile(data[self.tile_index], lib)
        ny, nx = len(self.tile_grid), len(self.tile_grid[0])
        rows = []
        for y, grid_row in enumerate(self.tile_grid):
            row = []
            for x, m_index in enumerate(grid_row):
                # crop overlap, except for last row/column
                height = self.tile_step[0] if y < ny - 1 else self.tile_size[0]
                width = self.tile_step[1] if x < nx - 1 else self.tile_size[1]
                if m_index is not None:
                    tile = self._transform_tile(data[m_index], lib)[..., :height, :width]
                else:
                    tile = lib.zeros(data.shape[1:-2] + (height, width), dtype=data.dtype)
                row.append(tile)
            rows.append(row)
        return lib.block(rows)

    def _transform_tile(self, tile, lib):
        # tiles are flipped / transposed as placed in the tile scan
        flip_y, flip_x, swap_xy = self.tile_flip
        if flip_y:
            tile = tile[..., ::-1, :]
        if flip_x:
            tile = tile[..., ::-1]
        if swap_xy:
            tile = lib.swapaxes(tile, -1, -2)
        return tile

    def is_screen(self):
        return False

    def get_shape(self):
        return self.shape

    def get_shapes(self):
        return self.shapes

    def get_scales(self):
        return self.scales

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        data = np.asarray(self._get_source_data())
        return redimension_data(data, self.dim_order, dim_order)

    def get_data_as_dask(self, dim_order, level=0, **kwargs):
        data = self._get_source_data(as_dask=True)
        return redimension_data(data, self.dim_order, dim_order)

    def get_name(self):
        return self.name

    def get_dim_order(self):
        return self.dim_order

    def get_dtype(self):
        return self.dtype

    def get_pixel_size_um(self):
        if self.pixel_size:
            return self.pixel_size
        else:
            return {'x': 1, 'y': 1}

    def get_position_um(self, well_id=None):
        return self.position

    def get_channels(self):
        return self.channels

    def get_nchannels(self):
        return self.nchannels

    def is_rgb(self):
        return self.is_rgb_channels

    def get_rows(self):
        return []

    def get_columns(self):
        return []

    def get_wells(self):
        return []

    def get_time_points(self):
        return list(range(self.shape[0]))

    def get_fields(self):
        return []

    def get_acquisitions(self):
        return []

    def get_acquisition_datetime(self):
        return self.acquisition_datetime

    def get_acquisition_end_datetime(self):
        return self.acquisition_end_datetime

    def get_significant_bits(self):
        return self.bits_per_pixel

    def get_acquisition_metadata(self):
        return self.acquisition_metadata

    def close(self):
        self.lif.close()

image = images[0] instance-attribute

lif = LifFile(uri) instance-attribute

position_index = int(position) if position is not None else None instance-attribute

__init__(uri, metadata={}, image_uuid=None, image_index=None, position=None, **kwargs)

Initialize LeicaSource.

Parameters:

Name Type Description Default
uri str

Path to the Leica file.

required
metadata dict

Optional metadata dictionary.

{}
image_uuid str

UUID of the image to read.

None
image_index int

Index of the image to read (e.g. for older files without UUIDs).

None
position int

Index of the position to read, for a tile scan with gaps between its tiles.

None
Source code in src/biomero_converter/LeicaSource.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def __init__(self, uri, metadata={}, image_uuid=None, image_index=None, position=None, **kwargs):
    """
    Initialize LeicaSource.

    Args:
        uri (str): Path to the Leica file.
        metadata (dict): Optional metadata dictionary.
        image_uuid (str, optional): UUID of the image to read.
        image_index (int, optional): Index of the image to read (e.g. for older files without UUIDs).
        position (int, optional): Index of the position to read, for a tile scan with gaps between its tiles.
    """
    super().__init__(uri, metadata)
    self.position_index = int(position) if position is not None else None
    self.lif = LifFile(uri)
    if image_uuid:
        images = [image for image in self.lif.images if image.uuid == image_uuid]
        if not images:
            raise ValueError(f'Image UUID {image_uuid} not found in {uri}')
        self.image = images[0]
    elif image_index is not None:
        self.image = self.lif.images[int(image_index)]
    else:
        if not self.lif.images:
            raise ValueError(f'No images found in {uri}')
        self.image = self.lif.images[0]

close()

Source code in src/biomero_converter/LeicaSource.py
483
484
def close(self):
    self.lif.close()

get_acquisition_datetime()

Source code in src/biomero_converter/LeicaSource.py
471
472
def get_acquisition_datetime(self):
    return self.acquisition_datetime

get_acquisition_end_datetime()

Source code in src/biomero_converter/LeicaSource.py
474
475
def get_acquisition_end_datetime(self):
    return self.acquisition_end_datetime

get_acquisition_metadata()

Source code in src/biomero_converter/LeicaSource.py
480
481
def get_acquisition_metadata(self):
    return self.acquisition_metadata

get_acquisitions()

Source code in src/biomero_converter/LeicaSource.py
468
469
def get_acquisitions(self):
    return []

get_channels()

Source code in src/biomero_converter/LeicaSource.py
444
445
def get_channels(self):
    return self.channels

get_columns()

Source code in src/biomero_converter/LeicaSource.py
456
457
def get_columns(self):
    return []

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Source code in src/biomero_converter/LeicaSource.py
418
419
420
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    data = np.asarray(self._get_source_data())
    return redimension_data(data, self.dim_order, dim_order)

get_data_as_dask(dim_order, level=0, **kwargs)

Source code in src/biomero_converter/LeicaSource.py
422
423
424
def get_data_as_dask(self, dim_order, level=0, **kwargs):
    data = self._get_source_data(as_dask=True)
    return redimension_data(data, self.dim_order, dim_order)

get_dim_order()

Source code in src/biomero_converter/LeicaSource.py
429
430
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/LeicaSource.py
432
433
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/LeicaSource.py
465
466
def get_fields(self):
    return []

get_image_count(uri) staticmethod

Get the number of images in a Leica file.

Parameters:

Name Type Description Default
uri str

Path to the Leica file.

required

Returns:

Name Type Description
int

Number of images.

Source code in src/biomero_converter/LeicaSource.py
116
117
118
119
120
121
122
123
124
125
126
127
128
@staticmethod
def get_image_count(uri):
    """
    Get the number of images in a Leica file.

    Args:
        uri (str): Path to the Leica file.

    Returns:
        int: Number of images.
    """
    with LifFile(uri) as lif:
        return len(lif.images)

get_name()

Source code in src/biomero_converter/LeicaSource.py
426
427
def get_name(self):
    return self.name

get_nchannels()

Source code in src/biomero_converter/LeicaSource.py
447
448
def get_nchannels(self):
    return self.nchannels

get_pixel_size_um()

Source code in src/biomero_converter/LeicaSource.py
435
436
437
438
439
def get_pixel_size_um(self):
    if self.pixel_size:
        return self.pixel_size
    else:
        return {'x': 1, 'y': 1}

get_position_um(well_id=None)

Source code in src/biomero_converter/LeicaSource.py
441
442
def get_position_um(self, well_id=None):
    return self.position

get_rows()

Source code in src/biomero_converter/LeicaSource.py
453
454
def get_rows(self):
    return []

get_scales()

Source code in src/biomero_converter/LeicaSource.py
415
416
def get_scales(self):
    return self.scales

get_shape()

Source code in src/biomero_converter/LeicaSource.py
409
410
def get_shape(self):
    return self.shape

get_shapes()

Source code in src/biomero_converter/LeicaSource.py
412
413
def get_shapes(self):
    return self.shapes

get_significant_bits()

Source code in src/biomero_converter/LeicaSource.py
477
478
def get_significant_bits(self):
    return self.bits_per_pixel

get_source_metadata()

All of the image's metadata, for the imaging metadata converter, but its path on this disk and what only lays out the pixel data in the file (a channel's or dimension's byte and bit increments): its attachments whole (the hardware settings without their ATL prefix, LAS AF's records keyed by their names), the channel and dimension descriptions, and the time stamps of its frames.

Source code in src/biomero_converter/LeicaSource.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def get_source_metadata(self):
    """
    All of the image's metadata, for the imaging metadata converter, but its path on this disk and what
    only lays out the pixel data in the file (a channel's or dimension's byte and bit increments): its
    attachments whole (the hardware settings without their ATL prefix, LAS AF's records keyed by their
    names), the channel and dimension descriptions, and the time stamps of its frames.
    """
    metadata = {'manufacturer': 'Leica Microsystems'}
    metadata |= {key: value for key, value in self.metadata.items() if key != 'filepath'}
    if isinstance(metadata.get('HardwareSetting'), dict):
        metadata['HardwareSetting'] = remove_key_prefix(metadata['HardwareSetting'], 'ATL')
    setting_list = metadata.get('HardwareSettingList')
    records = setting_list.get('HardwareSetting') if isinstance(setting_list, dict) else None
    if isinstance(records, dict) and records:
        metadata['HardwareSettingList'] = {
            **key_setting_records(records),
            **{key: value for key, value in setting_list.items() if key != 'HardwareSetting'}}
    description = self.image.xml_element.find('./Data/Image/ImageDescription')
    if description is not None:
        metadata['ImageDescription'] = without_keys(
            xml2dict(ElementTree.tostring(description, encoding='unicode'))['ImageDescription'], LAYOUT_KEYS)
    timestamps = self.image.timestamps
    if timestamps is not None and len(timestamps) > 0:
        metadata['timestamps'] = [str(timestamp) for timestamp in timestamps]
    return metadata

get_time_points()

Source code in src/biomero_converter/LeicaSource.py
462
463
def get_time_points(self):
    return list(range(self.shape[0]))

get_wells()

Source code in src/biomero_converter/LeicaSource.py
459
460
def get_wells(self):
    return []

init_metadata()

Source code in src/biomero_converter/LeicaSource.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def init_metadata(self):
    image = self.image
    self.metadata = image.attrs
    sizes = image.sizes
    coords = image.coords

    # map source dimensions to tczyx; RGB samples (S) as channels; other dimensions use index 0
    self.source_dims = image.dims
    self.is_rgb_channels = 'S' in sizes
    unsupported_dims = [dim for dim in self.source_dims if dim not in 'TCZYXSM']
    if unsupported_dims:
        logging.warning(f'Leica image {image.name}: unsupported dimension(s) {unsupported_dims}, using first index')

    self.dim_order = 'tczyx'
    self.tile_size = sizes['Y'], sizes['X']
    self.tile_flip = False, False, False
    self.tile_index = 0
    self.tile_position = None
    self.tile_grid = self._get_tile_grid(sizes.get('M', 1))
    if self.tile_grid is not None:
        ny, nx = len(self.tile_grid), len(self.tile_grid[0])
        height = (ny - 1) * self.tile_step[0] + self.tile_size[0]
        width = (nx - 1) * self.tile_step[1] + self.tile_size[1]
    else:
        height, width = self.tile_size
    nchannels = sizes.get('C', 1) * sizes.get('S', 1)
    self.shape = (sizes.get('T', 1), nchannels, sizes.get('Z', 1), height, width)
    self.shapes = [self.shape]
    self.scales = [1]
    self.nchannels = nchannels
    self.dtype = image.dtype

    self.pixel_size = {}
    self.position = {}
    for dim in 'xyz':
        coord = coords.get(dim.upper())
        if coord is not None and len(coord) > 0:
            self.position[dim] = float(coord[0]) * 1e6
            if len(coord) > 1:
                self.pixel_size[dim] = abs(float(coord[1] - coord[0])) * 1e6
    if self.tile_position is not None:
        self.position = self.tile_position

    channel_elements = sorted(
        image.xml_element.findall('./Data/Image/ImageDescription/Channels/ChannelDescription'),
        key=lambda element: int(element.attrib.get('BytesInc', 0)))
    self.bits_per_pixel = max([int(element.attrib.get('Resolution', 0)) for element in channel_elements],
                              default=self.dtype.itemsize * 8)
    self.channels = []
    if self.is_rgb_channels:
        for label, color in zip(['Red', 'Green', 'Blue'], [[1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]):
            self.channels.append({'label': label, 'color': color})
    else:
        labels = (sequential_channel_names(image.xml_element, nchannels)
                  or coords.get('C', [f'Ch{index}' for index in range(nchannels)]))
        # widefield channel names & emission, if matching channels
        widefield_infos = list(image.xml_element.iter('WideFieldChannelInfo'))
        if len(widefield_infos) != len(channel_elements):
            widefield_infos = [None] * len(channel_elements)
        for label, element, widefield_info in zip(labels, channel_elements, widefield_infos):
            channel = {'label': str(label)}
            color = LUT_COLORS.get(element.attrib.get('LUTName', '').lower())
            if color:
                channel['color'] = color
            if widefield_info is not None:
                if widefield_info.attrib.get('UserDefName'):
                    channel['label'] = widefield_info.attrib['UserDefName']
                emission = float(widefield_info.attrib.get('EmissionWavelength', 0))
                if emission > 0:
                    channel['emission_wavelength'] = emission
                    channel['emission_wavelength_unit'] = 'nm'
            self.channels.append(channel)

    timestamps = image.timestamps
    if timestamps is not None and len(timestamps) > 0:
        self.acquisition_datetime = timestamps[0].astype('datetime64[us]').item()
        self.acquisition_end_datetime = timestamps[-1].astype('datetime64[us]').item()
    else:
        self.acquisition_datetime = None
        self.acquisition_end_datetime = None

    self.acquisition_metadata = self._get_acquisition_metadata()
    filetitle = get_filetitle(self.uri)
    image_name = image.path.replace('/', '_')
    self.name = filetitle if image_name == filetitle else f'{filetitle}_{image_name}'
    if self.tile_position is not None:
        self.name += f'_pos{self.tile_index}'
    elif self.position_index is not None:
        logging.warning(f'Leica image {image.name}: not a tile scan with separate positions, position ignored')
    return self.metadata

is_rgb()

Source code in src/biomero_converter/LeicaSource.py
450
451
def is_rgb(self):
    return self.is_rgb_channels

is_screen()

Source code in src/biomero_converter/LeicaSource.py
406
407
def is_screen(self):
    return False

key_setting_records(hardware_setting)

LAS AF's hardware settings with each record keyed by its own name, so a value has a path of its own.

LAS AF lists its settings as records, a scanner setting named by its Identifier (dblZoom) and a filter setting by its ObjectName and Attribute (DM6000 Turret, NumericalAperture), the value in Variant. Each record is kept whole under its name, without the fields that name it; the few attributes an object repeats (an AOBS's intensity per line, a spectrophotometer mirror's left and right wavelength) keep their records as a list, as written.

Source code in src/biomero_converter/LeicaSource.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def key_setting_records(hardware_setting):
    """LAS AF's hardware settings with each record keyed by its own name, so a value has a path of its own.

    LAS AF lists its settings as records, a scanner setting named by its
    Identifier (dblZoom) and a filter setting by its ObjectName and Attribute
    (DM6000 Turret, NumericalAperture), the value in Variant. Each record is
    kept whole under its name, without the fields that name it; the few
    attributes an object repeats (an AOBS's intensity per line, a
    spectrophotometer mirror's left and right wavelength) keep their records
    as a list, as written.
    """
    keyed = {key: value for key, value in hardware_setting.items()
             if key not in ('ScannerSetting', 'FilterSetting', 'Name')}
    scanner = (hardware_setting.get('ScannerSetting') or {}).get('ScannerSettingRecord', [])
    keyed['ScannerSetting'] = {record['Identifier']: {key: value for key, value in record.items() if key != 'Identifier'}
                               for record in (scanner if isinstance(scanner, list) else [scanner])}
    filters = (hardware_setting.get('FilterSetting') or {}).get('FilterSettingRecord', [])
    keyed['FilterSetting'] = {}
    for record in filters if isinstance(filters, list) else [filters]:
        attributes = keyed['FilterSetting'].setdefault(record['ObjectName'], {})
        entry = {key: value for key, value in record.items() if key not in ('ObjectName', 'Attribute')}
        written = attributes.get(record['Attribute'])
        attributes[record['Attribute']] = entry if written is None else \
            (written + [entry] if isinstance(written, list) else [written, entry])
    return keyed

sequential_channel_names(xml_element, nchannels)

The channel names of a sequential confocal scan, or None where it is none.

Channel k is the k-th active detector, taken sequence by sequence, and its name the dye of that detector's spectral band. The bands are numbered by detector and stated once, not per sequence, so liffile, which names the channels after the bands in band order, misnames them wherever the sequences do not run in detector order (TileScan.lof: detectors 4, 5, 1).

Source code in src/biomero_converter/LeicaSource.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def sequential_channel_names(xml_element, nchannels):
    """The channel names of a sequential confocal scan, or None where it is none.

    Channel k is the k-th active detector, taken sequence by sequence, and its
    name the dye of that detector's spectral band. The bands are numbered by
    detector and stated once, not per sequence, so liffile, which names the
    channels after the bands in band order, misnames them wherever the
    sequences do not run in detector order (TileScan.lof: detectors 4, 5, 1).
    """
    hardware_setting = xml_element.find('./Data/Image/Attachment[@Name="HardwareSetting"]')
    settings = hardware_setting.find('ATLConfocalSettingDefinition') if hardware_setting is not None else None
    if settings is None:
        return None
    dyes = {band.attrib.get('Channel'): band.attrib.get('DyeName', '').removeprefix('Leica/')
            for band in settings.iter('MultiBand')}
    detectors = [detector.attrib.get('Channel') for sequences in hardware_setting.iter('LDM_Block_Sequential_List')
                 for sequence in sequences for detector in sequence.iter('Detector')
                 if detector.attrib.get('IsActive') == '1']
    if len(detectors) != nchannels:
        return None
    return [dyes.get(detector) or f'Ch{index}' for index, detector in enumerate(detectors)]

MiraxSource

OME_DIR = 'OME' module-attribute

OME_FILE = 'METADATA.ome.xml' module-attribute

PYRAMID_DOWNSCALE = 2 module-attribute

PYRAMID_LEVELS = 6 module-attribute

RETRY_ATTEMPTS = 3 module-attribute

TIFF_COMPRESSION = 'LZW' module-attribute

TILE_SIZE = 1024 module-attribute

VERSION = get_version() module-attribute

ZARR_CHUNK_SIZE = TILE_SIZE module-attribute

ZARR_SHARD_MULTIPLIER = 10 module-attribute

MiraxSource

Bases: ImageSource

ImageSource subclass for reading Mirax files using OpenSlide.

Source code in src/biomero_converter/MiraxSource.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
class MiraxSource(ImageSource):
    """
    ImageSource subclass for reading Mirax files using OpenSlide.
    """

    def __init__(self, uri, metadata={}):
        super().__init__(uri, metadata)
        self.slide = openslide.open_slide(uri)

    def init_metadata(self):
        self.metadata = {key.lower(): value for key, value in dict(self.slide.properties).items()}

        self.dimensions = self.slide.level_dimensions
        self.widths = [width for width, height in self.slide.level_dimensions]
        self.heights = [height for width, height in self.slide.level_dimensions]
        self.level_downsamples = self.slide.level_downsamples
        self.scales = [1 / downsample for downsample in self.level_downsamples]
        self.nchannels = 3      # Mirax is RGBA; convert to RGB
        self.shapes = [(height, width, self.nchannels) for (width, height) in self.dimensions]
        self.shape = self.shapes[0]
        self.dim_order = 'yxc'
        self.is_rgb_channels = True

        name = None
        nbits = 8
        acquisition_datetime = None
        for key, value in self.metadata.items():
            if 'slide_name' in key:
                name = value
            if 'slide_bitdepth' in key:
                nbits = int(value)
            if 'slide_creationdatetime' in key:
                acquisition_datetime = datetime.strptime(value,'%d/%m/%Y %H:%M:%S')
        self.dtype = get_bits_type(nbits)

        # OpenSlide stores microns per pixel in properties
        mpp_x = float(self.metadata.get(openslide.PROPERTY_NAME_MPP_X, 1))
        mpp_y = float(self.metadata.get(openslide.PROPERTY_NAME_MPP_Y, 1))
        self.pixel_size = {'x': mpp_x, 'y': mpp_y}
        background_float = hexrgb_to_rgba(self.metadata.get(openslide.PROPERTY_NAME_BACKGROUND_COLOR, '000000'))[:3]
        self.background = [np.uint8(value * 255) for value in background_float]

        if not name:
            name = get_filetitle(self.uri)
        self.name = name
        self.acquisition_datetime = acquisition_datetime
        self.bits_per_pixel = nbits
        return self.metadata

    def is_screen(self):
        # Mirax files are not multi-well screens
        return False

    def get_shape(self):
        return self.shape

    def get_shapes(self):
        return self.shapes

    def get_scales(self):
        return self.scales

    # TODO: check (x/y) source data is read in order first to last (currently last to first) using dask, or use generator/stream to dask?
    # read_tile_array(50000, 180000, 1000, 1000, 0)

    def read_array(self, x, y, width, height, level=0):
        # OpenSlide uses (x, y) coordinates in level 0 reference size
        x0 = int(x * self.level_downsamples[level])
        y0 = int(y * self.level_downsamples[level])
        #return np.array(self.slide.read_region((x0, y0), level, (width, height)).convert('RGB'))   # discard alpha
        rgba = np.array(self.slide.read_region((x0, y0), level, (width, height)))
        alpha = np.atleast_3d(rgba[..., 3] / np.float32(255))
        rgb = (rgba[..., :3] * alpha + self.background * (1 - alpha)).astype(np.uint8)
        return rgb

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        data = self.read_array(0, 0, self.widths[level], self.heights[level], level=level)
        return redimension_data(data, self.dim_order, dim_order)

    def get_data_as_dask(self, dim_order, level=0, **kwargs):
        dask.config.set(scheduler='single-threaded')

        def get_lazy_tile(x, y, width, height, level=0):
            lazy_array = dask.delayed(self.read_array)(x, y, width, height, level)
            return da.from_delayed(lazy_array, shape=(height, width, self.nchannels), dtype=self.dtype)

        y_chunks, x_chunks = da.core.normalize_chunks(TILE_SIZE, self.shapes[level][:2], dtype=self.dtype)
        y_pos = np.cumsum([0] + list(y_chunks)[:-1])
        x_pos = np.cumsum([0] + list(x_chunks)[:-1])
        data = da.concatenate(
            [da.concatenate(
                [get_lazy_tile(x, y, width, height, level=level)
                 for x, width in zip(x_pos, x_chunks)], axis=1)
             for y, height in zip(y_pos, y_chunks)], axis=0)
        return redimension_data(data, self.dim_order, dim_order)

    def get_data_as_generator(self, dim_order, **kwargs):
        def data_generator(scale=1):
            level, rescale = get_level_from_scale(self.scales, scale)
            read_size = int(TILE_SIZE / rescale)
            for y in range(0, self.heights[level], read_size):
                for x in range(0, self.widths[level], read_size):
                    data = self.read_array(x, y, read_size, read_size, level)
                    if rescale != 1:
                        shape = np.multiply(data.shape[:2], rescale).astype(int)
                        data = sk_transform.resize(data, shape, preserve_range=True).astype(data.dtype)
                    yield redimension_data(data, self.dim_order, dim_order)
        return data_generator

    def get_name(self):
        return self.name

    def get_dim_order(self):
        return self.dim_order

    def get_dtype(self):
        return self.dtype

    def get_pixel_size_um(self):
        return self.pixel_size

    def get_position_um(self, well_id=None):
        # Not applicable for Mirax
        return {'x': 0, 'y': 0}

    def get_channels(self):
        # Mirax is RGB, return NGFF-style channel metadata
        return [
            {"name": "Red", "color": [1, 0, 0, 1]},
            {"name": "Green", "color": [0, 1, 0, 1]},
            {"name": "Blue", "color": [0, 0, 1, 1]},
            #{"name": "Alpha", "color": [1, 1, 1, 1]}
        ]

    def get_nchannels(self):
        return self.nchannels

    def is_rgb(self):
        return True

    def get_rows(self):
        return []

    def get_columns(self):
        return []

    def get_wells(self):
        return []

    def get_time_points(self):
        return []

    def get_fields(self):
        return []

    def get_acquisitions(self):
        return []

    def get_acquisition_datetime(self):
        return self.acquisition_datetime

    def get_significant_bits(self):
        return self.bits_per_pixel

    def close(self):
        self.slide.close()

slide = openslide.open_slide(uri) instance-attribute

__init__(uri, metadata={})

Source code in src/biomero_converter/MiraxSource.py
21
22
23
def __init__(self, uri, metadata={}):
    super().__init__(uri, metadata)
    self.slide = openslide.open_slide(uri)

close()

Source code in src/biomero_converter/MiraxSource.py
180
181
def close(self):
    self.slide.close()

get_acquisition_datetime()

Source code in src/biomero_converter/MiraxSource.py
174
175
def get_acquisition_datetime(self):
    return self.acquisition_datetime

get_acquisitions()

Source code in src/biomero_converter/MiraxSource.py
171
172
def get_acquisitions(self):
    return []

get_channels()

Source code in src/biomero_converter/MiraxSource.py
141
142
143
144
145
146
147
148
def get_channels(self):
    # Mirax is RGB, return NGFF-style channel metadata
    return [
        {"name": "Red", "color": [1, 0, 0, 1]},
        {"name": "Green", "color": [0, 1, 0, 1]},
        {"name": "Blue", "color": [0, 0, 1, 1]},
        #{"name": "Alpha", "color": [1, 1, 1, 1]}
    ]

get_columns()

Source code in src/biomero_converter/MiraxSource.py
159
160
def get_columns(self):
    return []

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Source code in src/biomero_converter/MiraxSource.py
91
92
93
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    data = self.read_array(0, 0, self.widths[level], self.heights[level], level=level)
    return redimension_data(data, self.dim_order, dim_order)

get_data_as_dask(dim_order, level=0, **kwargs)

Source code in src/biomero_converter/MiraxSource.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def get_data_as_dask(self, dim_order, level=0, **kwargs):
    dask.config.set(scheduler='single-threaded')

    def get_lazy_tile(x, y, width, height, level=0):
        lazy_array = dask.delayed(self.read_array)(x, y, width, height, level)
        return da.from_delayed(lazy_array, shape=(height, width, self.nchannels), dtype=self.dtype)

    y_chunks, x_chunks = da.core.normalize_chunks(TILE_SIZE, self.shapes[level][:2], dtype=self.dtype)
    y_pos = np.cumsum([0] + list(y_chunks)[:-1])
    x_pos = np.cumsum([0] + list(x_chunks)[:-1])
    data = da.concatenate(
        [da.concatenate(
            [get_lazy_tile(x, y, width, height, level=level)
             for x, width in zip(x_pos, x_chunks)], axis=1)
         for y, height in zip(y_pos, y_chunks)], axis=0)
    return redimension_data(data, self.dim_order, dim_order)

get_data_as_generator(dim_order, **kwargs)

Source code in src/biomero_converter/MiraxSource.py
112
113
114
115
116
117
118
119
120
121
122
123
def get_data_as_generator(self, dim_order, **kwargs):
    def data_generator(scale=1):
        level, rescale = get_level_from_scale(self.scales, scale)
        read_size = int(TILE_SIZE / rescale)
        for y in range(0, self.heights[level], read_size):
            for x in range(0, self.widths[level], read_size):
                data = self.read_array(x, y, read_size, read_size, level)
                if rescale != 1:
                    shape = np.multiply(data.shape[:2], rescale).astype(int)
                    data = sk_transform.resize(data, shape, preserve_range=True).astype(data.dtype)
                yield redimension_data(data, self.dim_order, dim_order)
    return data_generator

get_dim_order()

Source code in src/biomero_converter/MiraxSource.py
128
129
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/MiraxSource.py
131
132
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/MiraxSource.py
168
169
def get_fields(self):
    return []

get_name()

Source code in src/biomero_converter/MiraxSource.py
125
126
def get_name(self):
    return self.name

get_nchannels()

Source code in src/biomero_converter/MiraxSource.py
150
151
def get_nchannels(self):
    return self.nchannels

get_pixel_size_um()

Source code in src/biomero_converter/MiraxSource.py
134
135
def get_pixel_size_um(self):
    return self.pixel_size

get_position_um(well_id=None)

Source code in src/biomero_converter/MiraxSource.py
137
138
139
def get_position_um(self, well_id=None):
    # Not applicable for Mirax
    return {'x': 0, 'y': 0}

get_rows()

Source code in src/biomero_converter/MiraxSource.py
156
157
def get_rows(self):
    return []

get_scales()

Source code in src/biomero_converter/MiraxSource.py
75
76
def get_scales(self):
    return self.scales

get_shape()

Source code in src/biomero_converter/MiraxSource.py
69
70
def get_shape(self):
    return self.shape

get_shapes()

Source code in src/biomero_converter/MiraxSource.py
72
73
def get_shapes(self):
    return self.shapes

get_significant_bits()

Source code in src/biomero_converter/MiraxSource.py
177
178
def get_significant_bits(self):
    return self.bits_per_pixel

get_time_points()

Source code in src/biomero_converter/MiraxSource.py
165
166
def get_time_points(self):
    return []

get_wells()

Source code in src/biomero_converter/MiraxSource.py
162
163
def get_wells(self):
    return []

init_metadata()

Source code in src/biomero_converter/MiraxSource.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def init_metadata(self):
    self.metadata = {key.lower(): value for key, value in dict(self.slide.properties).items()}

    self.dimensions = self.slide.level_dimensions
    self.widths = [width for width, height in self.slide.level_dimensions]
    self.heights = [height for width, height in self.slide.level_dimensions]
    self.level_downsamples = self.slide.level_downsamples
    self.scales = [1 / downsample for downsample in self.level_downsamples]
    self.nchannels = 3      # Mirax is RGBA; convert to RGB
    self.shapes = [(height, width, self.nchannels) for (width, height) in self.dimensions]
    self.shape = self.shapes[0]
    self.dim_order = 'yxc'
    self.is_rgb_channels = True

    name = None
    nbits = 8
    acquisition_datetime = None
    for key, value in self.metadata.items():
        if 'slide_name' in key:
            name = value
        if 'slide_bitdepth' in key:
            nbits = int(value)
        if 'slide_creationdatetime' in key:
            acquisition_datetime = datetime.strptime(value,'%d/%m/%Y %H:%M:%S')
    self.dtype = get_bits_type(nbits)

    # OpenSlide stores microns per pixel in properties
    mpp_x = float(self.metadata.get(openslide.PROPERTY_NAME_MPP_X, 1))
    mpp_y = float(self.metadata.get(openslide.PROPERTY_NAME_MPP_Y, 1))
    self.pixel_size = {'x': mpp_x, 'y': mpp_y}
    background_float = hexrgb_to_rgba(self.metadata.get(openslide.PROPERTY_NAME_BACKGROUND_COLOR, '000000'))[:3]
    self.background = [np.uint8(value * 255) for value in background_float]

    if not name:
        name = get_filetitle(self.uri)
    self.name = name
    self.acquisition_datetime = acquisition_datetime
    self.bits_per_pixel = nbits
    return self.metadata

is_rgb()

Source code in src/biomero_converter/MiraxSource.py
153
154
def is_rgb(self):
    return True

is_screen()

Source code in src/biomero_converter/MiraxSource.py
65
66
67
def is_screen(self):
    # Mirax files are not multi-well screens
    return False

read_array(x, y, width, height, level=0)

Source code in src/biomero_converter/MiraxSource.py
81
82
83
84
85
86
87
88
89
def read_array(self, x, y, width, height, level=0):
    # OpenSlide uses (x, y) coordinates in level 0 reference size
    x0 = int(x * self.level_downsamples[level])
    y0 = int(y * self.level_downsamples[level])
    #return np.array(self.slide.read_region((x0, y0), level, (width, height)).convert('RGB'))   # discard alpha
    rgba = np.array(self.slide.read_region((x0, y0), level, (width, height)))
    alpha = np.atleast_3d(rgba[..., 3] / np.float32(255))
    rgb = (rgba[..., :3] * alpha + self.background * (1 - alpha)).astype(np.uint8)
    return rgb

get_version()

Get the version from the GitHub release tag.

Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image), otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag), otherwise the version of the installed package (taken from the tag when it was built).

Returns:

Name Type Description
str

Version string, or 'unknown' if it cannot be determined.

Source code in src/biomero_converter/version.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def get_version():
    """
    Get the version from the GitHub release tag.

    Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image),
    otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag),
    otherwise the version of the installed package (taken from the tag when it was built).

    Returns:
        str: Version string, or 'unknown' if it cannot be determined.
    """
    version = os.environ.get('BIOMERO_CONVERTER_VERSION')
    if version:
        return version
    # only this repo's own checkout: an installed package may sit inside another git repo
    if os.path.exists(os.path.join(REPO_DIR, '.git')):
        try:
            return subprocess.run(['git', 'describe', '--tags', '--always'], cwd=REPO_DIR,
                                  capture_output=True, text=True, check=True, timeout=10).stdout.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    try:
        return 'v' + package_version('biomero-converter')
    except PackageNotFoundError:
        return 'unknown'

OmeTiffWriter

OME_DIR = 'OME' module-attribute

OME_FILE = 'METADATA.ome.xml' module-attribute

PYRAMID_DOWNSCALE = 2 module-attribute

PYRAMID_LEVELS = 6 module-attribute

RETRY_ATTEMPTS = 3 module-attribute

TIFF_COMPRESSION = 'LZW' module-attribute

TILE_SIZE = 1024 module-attribute

VERSION = get_version() module-attribute

ZARR_CHUNK_SIZE = TILE_SIZE module-attribute

ZARR_SHARD_MULTIPLIER = 10 module-attribute

OmeTiffWriter

Bases: OmeWriter

Writes image data and metadata to OME-TIFF files.

Source code in src/biomero_converter/OmeTiffWriter.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
class OmeTiffWriter(OmeWriter):
    """
    Writes image data and metadata to OME-TIFF files.
    """
    def __init__(self, verbose=False):
        """
        Initialize OmeTiffWriter.

        Args:
            verbose (bool): If True, prints progress info.
        """
        super().__init__()
        self.verbose = verbose

    def write(self, filepath, source, **kwargs):
        """
        Writes image or screen data to OME-TIFF files.

        Args:
            filepath (str): Output file path.
            source (ImageSource): Source object.
            **kwargs: Additional options (e.g. wells selection).

        Returns:
            dict: Containing output_path: str or list Output file path(s) and data window.
        """

        dim_order = ''
        source_dim_order = source.get_dim_order()
        if source.get_time_points():
            dim_order += 't'
        if 'c' in source_dim_order and not source.is_rgb():
            dim_order += 'c'
        if 'z' in source_dim_order:
            dim_order += 'z'
        dim_order += 'yx'
        if 'c' in source_dim_order and source.is_rgb():
            dim_order += 'c'
        self.dim_order = dim_order

        reset_ome_ids()
        if source.is_screen():
            filepath, total_size, window = self._write_screen(filepath, source, **kwargs)
        else:
            filepath, total_size, window = self._write_image(filepath, source, **kwargs)

        if self.verbose:
            print(f'Total data written: {print_hbytes(total_size)}')

        return {'output_path': filepath, 'window': window}

    def _write_screen(self, filename, source, **kwargs):
        """
        Writes multi-well screen data to separate TIFF files and companion metadata.

        Args:
            filename (str): Output file name.
            source (ImageSource): Source object.
            **kwargs: Additional options (e.g. wells selection).

        Returns:
            tuple: (List of output paths, total data size, image window)
        """
        # writes separate tiff files for each field, and separate metadata companion file
        window = []
        output_paths = []
        filepath, filename = os.path.split(filename)
        filetitle = os.path.splitext(filename)[0].removesuffix('.ome')

        companion_filename = os.path.join(filepath, filetitle + '.companion.ome')
        companion_uuid = create_uuid()

        wells = kwargs.get('wells', source.get_wells())
        fields = list(map(str, source.get_fields()))

        total_size = 0
        image_uuids = []
        image_filenames = []
        for well_id in wells:
            for field_id in fields:
                resolution, resolution_unit = create_resolution_metadata(source)
                data = source.get_data(self.dim_order, well_id=well_id, field_id=field_id)

                filename = f'{filetitle}'
                filename += f'_{pad_leading_zero(well_id)}'
                if field_id is not None:
                    filename += f'_{pad_leading_zero(field_id)}'
                filename = os.path.join(filepath, filename + '.ome.tiff')
                xml_metadata, image_uuid = create_binaryonly_metadata(os.path.basename(companion_filename), companion_uuid)

                size, window = self._write_tiff(filename, source, data,
                                                resolution=resolution, resolution_unit=resolution_unit,
                                                tile_size=TILE_SIZE, compression=TIFF_COMPRESSION,
                                                xml_metadata=xml_metadata,
                                                pyramid_levels=PYRAMID_LEVELS, pyramid_downscale=PYRAMID_DOWNSCALE,
                                                well_id=well_id, field_id=field_id, **kwargs)

                image_uuids.append(image_uuid)
                image_filenames.append(os.path.basename(filename))
                output_paths.append(filename)
                total_size += size

        xml_metadata = create_metadata(source,
                                       uuid=companion_uuid,
                                       image_uuids=image_uuids,
                                       image_filenames=image_filenames,
                                       wells=wells)
        with open(companion_filename, 'wb') as file:
            file.write(xml_metadata.encode())

        output_paths = [companion_filename] + output_paths

        return output_paths, total_size, window

    def _write_image(self, filename, source, **kwargs):
        """
        Writes single image data to a TIFF file.

        Args:
            filename (str): Output file name.
            source (ImageSource): Source object.
            **kwargs: Additional options.

        Returns:
            tuple: (Output path, data size)
        """
        xml_metadata = create_metadata(source, image_filenames=[filename])
        resolution, resolution_unit = create_resolution_metadata(source)
        data = source.get_data_as_generator(self.dim_order)

        size, window = self._write_tiff(filename, source, data,
                                        resolution=resolution, resolution_unit=resolution_unit,
                                        tile_size=TILE_SIZE, compression=TIFF_COMPRESSION,
                                        xml_metadata=xml_metadata,
                                        pyramid_levels=PYRAMID_LEVELS, pyramid_downscale=PYRAMID_DOWNSCALE,
                                        **kwargs)

        return filename, size, window

    def _write_tiff(self, filename, source, data,
                    resolution=None, resolution_unit=None, tile_size=None, compression=None, compressionargs=None,
                    xml_metadata=None, pyramid_levels=0, pyramid_downscale=2, well_id=None, field_id=None, **kwargs):
        """
        Writes image data to a TIFF file with optional pyramids and metadata.

        Args:
            filename (str): Output file name.
            source (ImageSource): Source object.
            data (ndarray or generator): Image data.
            resolution (tuple, optional): Pixel resolution.
            resolution_unit (str, optional): Resolution unit.
            tile_size (int or tuple, optional): Tile size.
            compression (str, optional): Compression type.
            xml_metadata (str, optional): OME-XML metadata.
            pyramid_levels (int): Number of pyramid levels.
            pyramid_downscale (int): Pyramid downscale factor.

        Returns:
            int: Data size in bytes.
        """
        is_generator = inspect.isgeneratorfunction(data)
        if is_generator:
            data_generator = data
            # the source's shape in this writer's dim order (they differ e.g. for RGB: channels last)
            source_shape = dict(zip(source.get_dim_order(), source.get_shape()))
            shape = [source_shape.get(dim, 1) for dim in self.dim_order]
            dtype = source.get_dtype()
        else:
            shape = list(data.shape)
            dtype = data.dtype

        x_index = self.dim_order.index('x')
        y_index = self.dim_order.index('y')
        # tifffile cannot write a 1x1 pixel level of multi-dimensional (e.g. multi-channel) data
        pyramid_levels = get_pyramid_levels(shape, self.dim_order, pyramid_levels, pyramid_downscale,
                                            min_level_size=2)
        if tile_size is not None:
            if isinstance(tile_size, int):
                tile_size = [tile_size] * 2
            if tile_size[0] > shape[y_index] or tile_size[1] > shape[x_index]:
                tile_size = None

        if xml_metadata is not None:
            # set ome=False to provide custom OME xml in description
            xml_metadata_bytes = xml_metadata.encode()
            is_ome = False
        else:
            xml_metadata_bytes = None
            is_ome = True

        # maximum size (w/o compression)
        if is_generator:
            data_size = np.prod(shape) * dtype.itemsize
        else:
            data_size = data.size * data.itemsize
        max_size = 0
        scale = 1
        for level in range(1 + pyramid_levels):
            max_size += data_size * scale ** 2
            scale /= pyramid_downscale
        is_bigtiff = (max_size > 2 ** 32)

        window_scanner = WindowScanner()
        with TiffWriter(filename, bigtiff=is_bigtiff, ome=is_ome) as writer:
            for level in range(pyramid_levels + 1):
                if level == 0:
                    scale = 1
                    subifds = pyramid_levels
                    subfiletype = None
                    new_shape = shape
                else:
                    scale /= pyramid_downscale
                    new_shape = list(shape)
                    new_shape[x_index] = int(shape[x_index] * scale)
                    new_shape[y_index] = int(shape[y_index] * scale)
                    if not is_generator:
                        data = resize(data, new_shape, preserve_range=True).astype(dtype)
                    subifds = None
                    subfiletype = FILETYPE.REDUCEDIMAGE
                    xml_metadata_bytes = None
                if is_generator:
                    data = data_generator(scale)
                writer.write(data, shape=tuple(new_shape), dtype=dtype, metadata={'axes': self.dim_order},
                             photometric='rgb' if source.is_rgb() else 'minisblack',
                             subifds=subifds, subfiletype=subfiletype,
                             resolution=resolution, resolutionunit=resolution_unit, tile=tile_size,
                             compression=compression, compressionargs=compressionargs,
                             description=xml_metadata_bytes)
                if level == pyramid_levels:
                    window = source.get_image_window(window_scanner, well_id=well_id, field_id=field_id, data=data)
        return data_size, window

verbose = verbose instance-attribute

__init__(verbose=False)

Initialize OmeTiffWriter.

Parameters:

Name Type Description Default
verbose bool

If True, prints progress info.

False
Source code in src/biomero_converter/OmeTiffWriter.py
19
20
21
22
23
24
25
26
27
def __init__(self, verbose=False):
    """
    Initialize OmeTiffWriter.

    Args:
        verbose (bool): If True, prints progress info.
    """
    super().__init__()
    self.verbose = verbose

write(filepath, source, **kwargs)

Writes image or screen data to OME-TIFF files.

Parameters:

Name Type Description Default
filepath str

Output file path.

required
source ImageSource

Source object.

required
**kwargs

Additional options (e.g. wells selection).

{}

Returns:

Name Type Description
dict

Containing output_path: str or list Output file path(s) and data window.

Source code in src/biomero_converter/OmeTiffWriter.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def write(self, filepath, source, **kwargs):
    """
    Writes image or screen data to OME-TIFF files.

    Args:
        filepath (str): Output file path.
        source (ImageSource): Source object.
        **kwargs: Additional options (e.g. wells selection).

    Returns:
        dict: Containing output_path: str or list Output file path(s) and data window.
    """

    dim_order = ''
    source_dim_order = source.get_dim_order()
    if source.get_time_points():
        dim_order += 't'
    if 'c' in source_dim_order and not source.is_rgb():
        dim_order += 'c'
    if 'z' in source_dim_order:
        dim_order += 'z'
    dim_order += 'yx'
    if 'c' in source_dim_order and source.is_rgb():
        dim_order += 'c'
    self.dim_order = dim_order

    reset_ome_ids()
    if source.is_screen():
        filepath, total_size, window = self._write_screen(filepath, source, **kwargs)
    else:
        filepath, total_size, window = self._write_image(filepath, source, **kwargs)

    if self.verbose:
        print(f'Total data written: {print_hbytes(total_size)}')

    return {'output_path': filepath, 'window': window}

camel_to_snake(name)

Source code in src/biomero_converter/util.py
303
304
305
def camel_to_snake(name):
    name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
    return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()

camel_to_snake_keys_dict(dct)

Source code in src/biomero_converter/util.py
295
296
297
298
299
300
def camel_to_snake_keys_dict(dct):
    if isinstance(dct, dict):
        result = {camel_to_snake(key): camel_to_snake_keys_dict(value) for key, value in dct.items()}
    else:
        result = dct
    return result

convert_dotnet_ticks_to_datetime(net_ticks)

Source code in src/biomero_converter/util.py
175
176
def convert_dotnet_ticks_to_datetime(net_ticks):
    return datetime(1, 1, 1) + timedelta(microseconds=net_ticks // 10)

convert_to_s(value, unit)

Source code in src/biomero_converter/util.py
319
320
321
322
323
324
def convert_to_s(value, unit):
    conversions = {
        'ns': 1e-9, 'µs': 1e-6, 'us': 1e-6, 'ms': 1e-3,
        's': 1, 'min': 60, 'h': 3600, 'd': 86400
    }
    return value * conversions.get(unit, 1)

convert_to_um(value, unit)

Source code in src/biomero_converter/util.py
308
309
310
311
312
313
314
315
316
def convert_to_um(value, unit):
    conversions = {
        'nm': 1e-3, 'nanometer': 1e-3,
        'µm': 1, 'um': 1, 'micrometer': 1, 'micron': 1,
        'mm': 1e3, 'millimeter': 1e3,
        'cm': 1e4, 'centimeter': 1e4,
        'm': 1e6, 'meter': 1e6
    }
    return value * conversions.get(unit, 1)

dicom_to_dict(obj)

Source code in src/biomero_converter/util.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def dicom_to_dict(obj):
    # Recursively convert a pydicom Dataset (or element value) to plain python types, using duck typing:
    # Dataset -> dict, Sequence / MultiValue -> list, DS / IS -> float / int, PersonName / UID -> str
    if hasattr(obj, 'iterall'):
        # Dataset: iterating yields its (top-level) data elements
        return {elem.keyword: dicom_to_dict(elem.value) for elem in obj
                if elem.keyword and elem.keyword != 'PixelData'}
    if obj is None or isinstance(obj, (bool, bytes)):
        return obj
    if isinstance(obj, int):
        return int(obj)
    if isinstance(obj, (float, Decimal)):
        return float(obj)
    if isinstance(obj, str):
        return str(obj)
    if isinstance(obj, Sequence):
        return [dicom_to_dict(item) for item in obj]
    return str(obj)

ensure_list(item)

Source code in src/biomero_converter/util.py
11
12
13
14
def ensure_list(item):
    if not isinstance(item, (list, tuple)):
        item = [item]
    return item

fix_bad_micro_value(value)

Source code in src/biomero_converter/util.py
365
366
367
368
def fix_bad_micro_value(value):
    if isinstance(value, str) and '\xa6\xcc' in value:
        value = value.replace('\xa6\xcc', '\xb5')
    return value

flatten_dict(dct, prefix='')

Source code in src/biomero_converter/util.py
281
282
283
284
285
286
287
288
289
290
291
292
def flatten_dict(dct, prefix=''):
    flat_dct = {}
    for key, value in dct.items():
        full_key = prefix + ':' + key if prefix else key
        if isinstance(value, dict):
            flat_dct.update(flatten_dict(value, full_key))
        elif isinstance(value, (list, tuple)):
            for index, item in enumerate(value):
                flat_dct.update(flatten_dict({str(index): item}, full_key))
        else:
            flat_dct[full_key] = value
    return flat_dct

get_bits_type(nbits)

Source code in src/biomero_converter/util.py
77
78
79
80
81
82
83
84
85
86
def get_bits_type(nbits):
    if nbits <= 8:
        dtype = np.uint8
    elif nbits <= 16:
        dtype = np.uint16
    elif nbits <= 32:
        dtype = np.uint32
    else:
        dtype = np.uint64
    return np.dtype(dtype)

get_filetitle(filename)

Source code in src/biomero_converter/util.py
93
94
def get_filetitle(filename):
    return os.path.basename(os.path.splitext(filename)[0])

get_level_from_scale(source_scales, target_scale=1)

Source code in src/biomero_converter/util.py
67
68
69
70
71
72
73
74
def get_level_from_scale(source_scales, target_scale=1):
    best_level_scale = 0, target_scale
    for level, scale in enumerate(source_scales):
        if np.isclose(scale, target_scale, rtol=1e-4):
            return level, 1
        if scale <= target_scale:
            best_level_scale = level, target_scale / scale
    return best_level_scale

get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size)

Source code in src/biomero_converter/util.py
43
44
45
46
47
48
49
50
51
52
53
54
55
def get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size):
    x_index = dim_order.index('x')
    y_index = dim_order.index('y')
    slices = [slice(None)] * len(dim_order)
    if 't' in dim_order:
        slices[dim_order.index('t')] = t
    if 'c' in dim_order and c is not None:  # None: all channels
        slices[dim_order.index('c')] = c
    if 'z' in dim_order:
        slices[dim_order.index('z')] = z
    slices[y_index] = slice(y, y + y_size)
    slices[x_index] = slice(x, x + x_size)
    return data[tuple(slices)]

get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1)

Source code in src/biomero_converter/util.py
58
59
60
61
62
63
64
def get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1):
    # limit number of pyramid levels, keeping the smallest level at least min_level_size pixels
    min_size = min(shape[dim_order.index('x')], shape[dim_order.index('y')])
    levels = 0
    while levels < max_levels and min_size / downscale ** (levels + 1) >= min_level_size:
        levels += 1
    return levels

get_rows_cols_plate(nwells)

Source code in src/biomero_converter/util.py
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_rows_cols_plate(nwells):
    nrows_cols = {
        6: (2, 3),
        12: (3, 4),
        24: (4, 6),
        48: (6, 8),
        96: (8, 12),
        384: (16, 24)
    }
    nrows, ncols = nrows_cols[nwells]
    rows = [chr(ord('A') + i) for i in range(nrows)]
    cols = [str(i + 1) for i in range(ncols)]
    return rows, cols

get_version()

Get the version from the GitHub release tag.

Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image), otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag), otherwise the version of the installed package (taken from the tag when it was built).

Returns:

Name Type Description
str

Version string, or 'unknown' if it cannot be determined.

Source code in src/biomero_converter/version.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def get_version():
    """
    Get the version from the GitHub release tag.

    Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image),
    otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag),
    otherwise the version of the installed package (taken from the tag when it was built).

    Returns:
        str: Version string, or 'unknown' if it cannot be determined.
    """
    version = os.environ.get('BIOMERO_CONVERTER_VERSION')
    if version:
        return version
    # only this repo's own checkout: an installed package may sit inside another git repo
    if os.path.exists(os.path.join(REPO_DIR, '.git')):
        try:
            return subprocess.run(['git', 'describe', '--tags', '--always'], cwd=REPO_DIR,
                                  capture_output=True, text=True, check=True, timeout=10).stdout.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    try:
        return 'v' + package_version('biomero-converter')
    except PackageNotFoundError:
        return 'unknown'

pad_leading_zero(input_string, num_digits=2)

Source code in src/biomero_converter/util.py
129
130
131
132
133
134
135
136
137
138
139
def pad_leading_zero(input_string, num_digits=2):
    output = str(input_string)
    is_well = not output[0].isdigit()
    if is_well:
        row, col = split_well_name(output, remove_leading_zeros=True)
        output = str(col)
    while len(output) < num_digits:
        output = '0' + output
    if is_well:
        output = row + output
    return output

parse_datetime(value)

A datetime from an ISO 8601 or other common date text, or None if it is none.

Source code in src/biomero_converter/util.py
162
163
164
165
166
167
168
169
170
171
172
def parse_datetime(value):
    """A datetime from an ISO 8601 or other common date text, or None if it is none."""
    if not value or isinstance(value, datetime):
        return value or None
    try:
        return datetime.fromisoformat(str(value))
    except ValueError:
        try:
            return dateutil.parser.parse(str(value))
        except (ValueError, OverflowError):
            return None

print_dict(value, tab=0, max_len=250, bullet=False)

Source code in src/biomero_converter/util.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def print_dict(value, tab=0, max_len=250, bullet=False):
    s = ''
    if isinstance(value, dict):
        for key, subvalue in value.items():
            s += '\n'
            if bullet:
                s += '-'
                bullet = False
            s += '\t' * tab + str(key) + ': '
            if isinstance(subvalue, dict):
                s += print_dict(subvalue, tab+1)
            elif isinstance(subvalue, list):
                for v in subvalue:
                    s += print_dict(v, tab+1, bullet=True)
            else:
                subvalue = str(subvalue)
                if len(subvalue) > max_len:
                    subvalue = subvalue[:max_len] + '...'
                s += subvalue
    else:
        s += str(value) + ' '
    return s

print_hbytes(nbytes)

Source code in src/biomero_converter/util.py
351
352
353
354
355
356
357
358
359
360
361
362
def print_hbytes(nbytes):
    exps = ['', 'K', 'M', 'G', 'T', 'P', 'E']
    div = 1024
    exp = 0
    while nbytes > div:
        nbytes /= div
        exp += 1
    if exp < len(exps):
        e = exps[exp]
    else:
        e = f'e{exp * 3}'
    return f'{nbytes:.1f}{e}B'

redimension_data(data, old_order, new_order, **indices)

Source code in src/biomero_converter/util.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def redimension_data(data, old_order, new_order, **indices):
    # able to provide optional dimension values e.g. t=0, z=0
    if new_order == old_order:
        return data

    new_data = data
    order = old_order
    # remove
    for o in old_order:
        if o not in new_order:
            index = order.index(o)
            dim_value = indices.get(o, 0)
            new_data = np.take(new_data, indices=dim_value, axis=index)
            order = order[:index] + order[index + 1:]
    # add
    for o in new_order:
        if o not in order:
            new_data = np.expand_dims(new_data, 0)
            order = o + order
    # move
    old_indices = [order.index(o) for o in new_order]
    new_indices = list(range(len(new_order)))
    new_data = np.moveaxis(new_data, old_indices, new_indices)
    return new_data

remove_key_prefix(obj, prefix)

Source code in src/biomero_converter/util.py
271
272
273
274
275
276
277
278
def remove_key_prefix(obj, prefix):
    # recursively remove prefix from dict keys
    if isinstance(obj, dict):
        return {(key[len(prefix):] if key.startswith(prefix) and len(key) > len(prefix) else key):
                remove_key_prefix(value, prefix) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [remove_key_prefix(item, prefix) for item in obj]
    return obj

split_well_name(well_name, remove_leading_zeros=True, col_as_int=False)

Source code in src/biomero_converter/util.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def split_well_name(well_name, remove_leading_zeros=True, col_as_int=False):
    matches = re.findall(r'(\D+)(\d+)', well_name)
    if len(matches) > 0:
        row, col = matches[0]
        if col_as_int or remove_leading_zeros:
            try:
                col = int(col)
            except ValueError:
                pass
        if not col_as_int:
            col = str(col)
        return row, col
    else:
        raise ValueError(f"Invalid well name format: {well_name}. Expected format like 'A1', 'B2', etc.")

splitall(path)

Source code in src/biomero_converter/util.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def splitall(path):
    allparts = []
    while True:
        parts = os.path.split(path)
        if parts[0] == path:  # sentinel for absolute paths
            allparts.insert(0, parts[0])
            break
        elif parts[1] == path: # sentinel for relative paths
            allparts.insert(0, parts[1])
            break
        else:
            path = parts[0]
            allparts.insert(0, parts[1])
    return allparts

strip_leading_zeros(well_name)

Source code in src/biomero_converter/util.py
142
143
144
def strip_leading_zeros(well_name):
    row, col = split_well_name(well_name, remove_leading_zeros=True)
    return f'{row}{col}'

to_plain_types(obj)

Source code in src/biomero_converter/util.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def to_plain_types(obj):
    # Recursively convert metadata to plain python types (e.g. for yaml): tuple -> list, numpy -> python,
    # bytes -> str, Enum -> name; datetime is kept, anything else unknown becomes str
    if isinstance(obj, dict):
        return {str(key): to_plain_types(value) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [to_plain_types(item) for item in obj]
    if isinstance(obj, np.ndarray):
        return to_plain_types(obj.tolist())
    if isinstance(obj, np.generic):
        return obj.item()
    if isinstance(obj, bytes):
        return obj.decode('utf-8', errors='replace')
    if isinstance(obj, Enum):
        return obj.name
    if obj is None or isinstance(obj, (bool, int, float, str, datetime)):
        return obj
    return str(obj)

validate_filename(filename)

Source code in src/biomero_converter/util.py
89
90
def validate_filename(filename):
    return re.sub(r'[^\w_.)(-]', '_', filename)

without_keys(value, keys)

Source code in src/biomero_converter/util.py
262
263
264
265
266
267
268
def without_keys(value, keys):
    # recursively drop the entries named by keys
    if isinstance(value, dict):
        return {key: without_keys(item, keys) for key, item in value.items() if key not in keys}
    if isinstance(value, (list, tuple)):
        return [without_keys(item, keys) for item in value]
    return value

xml_content_to_dict(element)

Source code in src/biomero_converter/util.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def xml_content_to_dict(element):
    key = element.tag
    children = list(element)
    if key == 'Array':
        res = [xml_content_to_dict(child) for child in children]
        return res
    if len(children) > 0:
        if children[0].tag == 'Array':
            value = []
        else:
            value = {}
        for child in children:
            child_value = xml_content_to_dict(child)
            if isinstance(child_value, list):
                value.extend(child_value)
            else:
                value |= child_value
    else:
        value = element.text
        if value is not None:
            if '"' in value:
                value = value.replace('"', '')
            else:
                for t in (float, int, bool):
                    try:
                        if t == bool:
                            if value.lower() == 'true':
                                value = True
                            if value.lower() == 'false':
                                value = False
                        else:
                            value = t(value)
                        break
                    except (TypeError, ValueError):
                        pass

    if key == 'DataObject':
        key = element.attrib['ObjectType']
    if key == 'Attribute':
        key = element.attrib['Name']
    return {key: value}

OmeWriter

OmeWriter

Bases: ABC

Abstract base class for OME writers.

Source code in src/biomero_converter/OmeWriter.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class OmeWriter(ABC):
    """
    Abstract base class for OME writers.
    """

    def write(self, filepath, source, verbose=False, **kwargs) -> str:
        """
        Write image data and metadata to output.

        Args:
            filepath (str): Output file path.
            source (ImageSource): Source object.
            verbose (bool): If True, prints progress info.
            **kwargs: Additional options.

        Returns:
            dict: Containing output_path: str or list Output file path(s), and other optional output.
        """
        # Expect to return output path (or filepath)
        raise NotImplementedError("This method should be implemented by subclasses.")

write(filepath, source, verbose=False, **kwargs)

Write image data and metadata to output.

Parameters:

Name Type Description Default
filepath str

Output file path.

required
source ImageSource

Source object.

required
verbose bool

If True, prints progress info.

False
**kwargs

Additional options.

{}

Returns:

Name Type Description
dict str

Containing output_path: str or list Output file path(s), and other optional output.

Source code in src/biomero_converter/OmeWriter.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def write(self, filepath, source, verbose=False, **kwargs) -> str:
    """
    Write image data and metadata to output.

    Args:
        filepath (str): Output file path.
        source (ImageSource): Source object.
        verbose (bool): If True, prints progress info.
        **kwargs: Additional options.

    Returns:
        dict: Containing output_path: str or list Output file path(s), and other optional output.
    """
    # Expect to return output path (or filepath)
    raise NotImplementedError("This method should be implemented by subclasses.")

OmeZarrSource

OME_DIR = 'OME' module-attribute

OME_FILE = 'METADATA.ome.xml' module-attribute

PYRAMID_DOWNSCALE = 2 module-attribute

PYRAMID_LEVELS = 6 module-attribute

RETRY_ATTEMPTS = 3 module-attribute

TIFF_COMPRESSION = 'LZW' module-attribute

TILE_SIZE = 1024 module-attribute

VERSION = get_version() module-attribute

ZARR_CHUNK_SIZE = TILE_SIZE module-attribute

ZARR_SHARD_MULTIPLIER = 10 module-attribute

OmeZarrSource

Bases: ImageSource

Source code in src/biomero_converter/OmeZarrSource.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
class OmeZarrSource(ImageSource):

    def _get_reader(self, add_path=None):
        uri = self.uri
        if add_path:
            uri = os.path.join(uri, add_path)
        location = parse_url(uri)
        if location is None:
            raise FileNotFoundError(f'Error parsing ome-zarr file {uri}')
        reader = Reader(location)
        nodes = list(reader())
        return reader, nodes

    def _get_metadata(self, add_path=None):
        metadata = {}
        _, nodes = self._get_reader(add_path)
        if len(nodes) > 0:
            metadata = self._convert_metadata_from_v06(nodes[0].metadata, nodes[0].zarr.root_attrs)
        return metadata

    def _convert_metadata_from_v06(self, metadata, attrs):
        # ome-zarr-py does not support 0.6 (RFC-5) yet: get axes and transformations from the image attributes
        multiscales = attrs.get('multiscales')
        if not multiscales or 'coordinateSystems' not in multiscales[0]:
            return metadata
        multiscale = convert_multiscales_from_v06(multiscales)[0]
        return {**metadata, 'axes': multiscale['axes'],
                'coordinateTransformations': [dataset['coordinateTransformations']
                                              for dataset in multiscale['datasets']]}

    def init_metadata(self):
        reader, nodes = self._get_reader()
        if 'bioformats2raw.layout' in reader.zarr.root_attrs:
            # TODO: use paths provided in metadata
            reader, nodes = self._get_reader('0')
        # nodes may include images, labels etc
        if len(nodes) == 0:
            raise FileNotFoundError(f'No image data found in ome-zarr file {self.uri}')
        # first node will be the image pixel data
        image_node = nodes[0]
        self.metadata = image_node.metadata
        plate = self.metadata.get('metadata', {}).get('plate')
        image_attrs = image_node.zarr.root_attrs
        if plate is not None and image_attrs.get('version') == '0.6':
            # plate node metadata is taken from the first image of the first well
            well_path = plate['wells'][0]['path']
            well_attrs = self._get_reader(well_path)[0].zarr.root_attrs
            image_path = well_path + '/' + well_attrs['well']['images'][0]['path']
            image_attrs = self._get_reader(image_path)[0].zarr.root_attrs
        self.metadata = self._convert_metadata_from_v06(self.metadata, image_attrs)
        # channel metadata from ome-zarr-py limited; get from root_attrs manually
        #self.root_metadata = reader.zarr.root_attrs

        axes = self.metadata.get('axes', [])
        self.dim_order = ''.join([axis.get('name') for axis in axes])
        units = {axis['name']: axis['unit'] for axis in axes if 'unit' in axis}
        self.plate = self.metadata.get('metadata', {}).get('plate')
        self.is_plate = self.plate is not None

        scales = [transform['scale'] for transform_set in self.metadata['coordinateTransformations']
                  for transform in transform_set if transform['type'] == 'scale']
        self.pixel_size = {dim: convert_to_um(pixel_size, units.get(dim, '')) for dim, pixel_size
                           in zip(self.dim_order, scales[0]) if dim in 'xyz'}
        x_index, y_index = self.dim_order.index('x'), self.dim_order.index('y')
        scale0 = np.mean([scales[0][x_index] + scales[0][y_index]])
        self.scales = [float(scale0 / np.mean([scale[x_index] + scale[y_index]])) for scale in scales]
        if self.is_plate:
            self.name = self.plate.get('name', '')
            self.rows = [row['name'] for row in self.plate.get('rows', [])]
            self.columns = [column['name'] for column in self.plate.get('columns', [])]
            self.wells = {well['path'].replace('/', ''): well['path'] for well in self.plate.get('wells')}
            self.fields = list(range(self.plate.get('field_count', 0)))
            self.paths = {well_id: {field: f'{well_path}/{field}' for field in self.fields} for well_id, well_path in self.wells.items()}
            self.acquisitions = self.plate.get('acquisitions', [])
            self.acquisition_datetime = datetime.fromisoformat(self.acquisitions[0]['date_created'])
            self.data = None    # data will be read per plate well
        else:
            self.name = self.metadata.get('name', '')
            # from the OME-XML below if it has one; not the file's time, which changes as the file is copied
            self.acquisition_datetime = None
            self.data = image_node.data
        if not self.name:
            self.name = get_filetitle(self.uri)
        self.name = str(self.name).removesuffix('.ome')

        self.shapes = [data.shape for data in image_node.data]
        self.shape = self.shapes[0]
        self.heights = [shape[y_index] for shape in self.shapes]
        self.widths = [shape[x_index] for shape in self.shapes]
        self.dtype = image_node.data[0].dtype
        self.bits_per_pixel = self.dtype.itemsize * 8

        self.channels = []
        colormaps = self.metadata.get('colormap', [])
        for channeli, channel_name in enumerate(self.metadata.get('channel_names', [])):
            channel = {'label': channel_name}
            if channeli < len(colormaps):
                channel['color'] = colormaps[channeli][-1]
            self.channels.append(channel)

        ome_xml_path = image_node.zarr.subpath(os.path.join(OME_DIR, OME_FILE))
        if os.path.exists(ome_xml_path):
            ome_xml_metadata = open(ome_xml_path, encoding='utf-8').read()
            ome_metadata = metadata_to_dict(ome_xml_metadata)
            (name, is_plate, pixel_size, position, dtype, bits_per_pixel, channels, microscope_info,
             acquisition_datetime,
             wells, rows, columns, fields, image_refs) = read_ome_xml_metadata(ome_metadata)
            for channel, ome_channel in zip(self.channels, channels):
                for key, value in ome_channel.items():
                    if key not in channel:
                        channel[key] = value
            self.microscope_info = microscope_info
            if self.acquisition_datetime is None:
                self.acquisition_datetime = parse_datetime(acquisition_datetime)

        return self.metadata

    def is_screen(self):
        return self.is_plate

    def get_shape(self):
        return self.shape

    def get_shapes(self):
        return self.shapes

    def get_scales(self):
        return self.scales

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        if well_id is None and field_id is None:
            data = self.data[level]
        else:
            _, nodes = self._get_reader(self.paths[well_id][int(field_id)])
            data = nodes[0].data[level]
        return redimension_data(data, self.dim_order, dim_order)

    def get_data_as_generator(self, dim_order, **kwargs):
        def data_generator(scale=1):
            level, rescale = get_level_from_scale(self.scales, scale)
            level_data = self.data[level]
            read_size = int(TILE_SIZE / rescale)
            nz = self.shape[self.dim_order.index('z')] if 'z' in self.dim_order else 1
            # RGB(A) is written with the channels as samples (last): each tile holds all channels
            samples = dim_order.endswith('c')
            channels = [None] if samples else range(self.get_nchannels())
            tile_dims = 'yxc' if samples else 'yx'
            data_order = ''.join(dim for dim in self.dim_order if dim in tile_dims)
            tile_order = ''.join(dim for dim in dim_order if dim in tile_dims)
            for t in range(len(self.get_time_points())):
                for c in channels:
                    for z in range(nz):
                        for y in range(0, self.heights[level], read_size):
                            for x in range(0, self.widths[level], read_size):
                                data = get_numpy_data(level_data, self.dim_order, t, c, z, y, x, read_size, read_size)
                                if rescale != 1:
                                    new_shape = [int(size * rescale) if dim in 'yx' else size
                                                 for dim, size in zip(data_order, data.shape)]
                                    data = sk_transform.resize(data, new_shape,
                                                               preserve_range=True).astype(data.dtype)
                                yield redimension_data(np.asarray(data), data_order, tile_order)
        return data_generator

    def get_image_window(self, window_scanner, well_id=None, field_id=None, data=None):
        if well_id is None and field_id is None:
            metadata = self.metadata
        else:
            metadata = self._get_metadata(self.paths[well_id][int(field_id)])
        window = np.transpose(metadata.get('contrast_limits', ([], [])))
        return window

    def get_name(self):
        return self.name

    def get_dim_order(self):
        return self.dim_order

    def get_dtype(self):
        return self.dtype

    def get_pixel_size_um(self):
        return self.pixel_size

    def get_position_um(self, well_id=None):
        if well_id is not None:
            metadata = self._get_metadata(self.paths[well_id][0])
        else:
            metadata = self.metadata
        for transforms in metadata['coordinateTransformations'][0]:
            if transforms['type'] == 'translation':
                return {dim:value for dim, value in zip(self.dim_order, transforms['translation'])}
        return {}

    def get_channels(self):
        return self.channels

    def get_nchannels(self):
        return self.shape[self.dim_order.index('c')] if 'c' in self.dim_order else 1

    def is_rgb(self):
        # OME-Zarr has no RGB flag: 8-bit 3/4-channel images are taken as RGB(A), not e.g. 16-bit fluorescence
        return self.get_nchannels() in (3, 4) and self.get_dtype() == np.uint8

    def get_rows(self):
        return self.rows

    def get_columns(self):
        return self.columns

    def get_wells(self):
        return self.wells

    def get_time_points(self):
        nt = self.shape[self.dim_order.index('t')] if 't' in self.dim_order else 1
        return list(range(nt))

    def get_fields(self):
        return self.fields

    def get_acquisitions(self):
        return self.acquisitions

    def get_acquisition_datetime(self):
        return self.acquisition_datetime

    def get_significant_bits(self):
        return self.bits_per_pixel

    def get_acquisition_metadata(self):
        return self.microscope_info

get_acquisition_datetime()

Source code in src/biomero_converter/OmeZarrSource.py
236
237
def get_acquisition_datetime(self):
    return self.acquisition_datetime

get_acquisition_metadata()

Source code in src/biomero_converter/OmeZarrSource.py
242
243
def get_acquisition_metadata(self):
    return self.microscope_info

get_acquisitions()

Source code in src/biomero_converter/OmeZarrSource.py
233
234
def get_acquisitions(self):
    return self.acquisitions

get_channels()

Source code in src/biomero_converter/OmeZarrSource.py
207
208
def get_channels(self):
    return self.channels

get_columns()

Source code in src/biomero_converter/OmeZarrSource.py
220
221
def get_columns(self):
    return self.columns

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Source code in src/biomero_converter/OmeZarrSource.py
143
144
145
146
147
148
149
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    if well_id is None and field_id is None:
        data = self.data[level]
    else:
        _, nodes = self._get_reader(self.paths[well_id][int(field_id)])
        data = nodes[0].data[level]
    return redimension_data(data, self.dim_order, dim_order)

get_data_as_generator(dim_order, **kwargs)

Source code in src/biomero_converter/OmeZarrSource.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def get_data_as_generator(self, dim_order, **kwargs):
    def data_generator(scale=1):
        level, rescale = get_level_from_scale(self.scales, scale)
        level_data = self.data[level]
        read_size = int(TILE_SIZE / rescale)
        nz = self.shape[self.dim_order.index('z')] if 'z' in self.dim_order else 1
        # RGB(A) is written with the channels as samples (last): each tile holds all channels
        samples = dim_order.endswith('c')
        channels = [None] if samples else range(self.get_nchannels())
        tile_dims = 'yxc' if samples else 'yx'
        data_order = ''.join(dim for dim in self.dim_order if dim in tile_dims)
        tile_order = ''.join(dim for dim in dim_order if dim in tile_dims)
        for t in range(len(self.get_time_points())):
            for c in channels:
                for z in range(nz):
                    for y in range(0, self.heights[level], read_size):
                        for x in range(0, self.widths[level], read_size):
                            data = get_numpy_data(level_data, self.dim_order, t, c, z, y, x, read_size, read_size)
                            if rescale != 1:
                                new_shape = [int(size * rescale) if dim in 'yx' else size
                                             for dim, size in zip(data_order, data.shape)]
                                data = sk_transform.resize(data, new_shape,
                                                           preserve_range=True).astype(data.dtype)
                            yield redimension_data(np.asarray(data), data_order, tile_order)
    return data_generator

get_dim_order()

Source code in src/biomero_converter/OmeZarrSource.py
188
189
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/OmeZarrSource.py
191
192
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/OmeZarrSource.py
230
231
def get_fields(self):
    return self.fields

get_image_window(window_scanner, well_id=None, field_id=None, data=None)

Source code in src/biomero_converter/OmeZarrSource.py
177
178
179
180
181
182
183
def get_image_window(self, window_scanner, well_id=None, field_id=None, data=None):
    if well_id is None and field_id is None:
        metadata = self.metadata
    else:
        metadata = self._get_metadata(self.paths[well_id][int(field_id)])
    window = np.transpose(metadata.get('contrast_limits', ([], [])))
    return window

get_name()

Source code in src/biomero_converter/OmeZarrSource.py
185
186
def get_name(self):
    return self.name

get_nchannels()

Source code in src/biomero_converter/OmeZarrSource.py
210
211
def get_nchannels(self):
    return self.shape[self.dim_order.index('c')] if 'c' in self.dim_order else 1

get_pixel_size_um()

Source code in src/biomero_converter/OmeZarrSource.py
194
195
def get_pixel_size_um(self):
    return self.pixel_size

get_position_um(well_id=None)

Source code in src/biomero_converter/OmeZarrSource.py
197
198
199
200
201
202
203
204
205
def get_position_um(self, well_id=None):
    if well_id is not None:
        metadata = self._get_metadata(self.paths[well_id][0])
    else:
        metadata = self.metadata
    for transforms in metadata['coordinateTransformations'][0]:
        if transforms['type'] == 'translation':
            return {dim:value for dim, value in zip(self.dim_order, transforms['translation'])}
    return {}

get_rows()

Source code in src/biomero_converter/OmeZarrSource.py
217
218
def get_rows(self):
    return self.rows

get_scales()

Source code in src/biomero_converter/OmeZarrSource.py
140
141
def get_scales(self):
    return self.scales

get_shape()

Source code in src/biomero_converter/OmeZarrSource.py
134
135
def get_shape(self):
    return self.shape

get_shapes()

Source code in src/biomero_converter/OmeZarrSource.py
137
138
def get_shapes(self):
    return self.shapes

get_significant_bits()

Source code in src/biomero_converter/OmeZarrSource.py
239
240
def get_significant_bits(self):
    return self.bits_per_pixel

get_time_points()

Source code in src/biomero_converter/OmeZarrSource.py
226
227
228
def get_time_points(self):
    nt = self.shape[self.dim_order.index('t')] if 't' in self.dim_order else 1
    return list(range(nt))

get_wells()

Source code in src/biomero_converter/OmeZarrSource.py
223
224
def get_wells(self):
    return self.wells

init_metadata()

Source code in src/biomero_converter/OmeZarrSource.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def init_metadata(self):
    reader, nodes = self._get_reader()
    if 'bioformats2raw.layout' in reader.zarr.root_attrs:
        # TODO: use paths provided in metadata
        reader, nodes = self._get_reader('0')
    # nodes may include images, labels etc
    if len(nodes) == 0:
        raise FileNotFoundError(f'No image data found in ome-zarr file {self.uri}')
    # first node will be the image pixel data
    image_node = nodes[0]
    self.metadata = image_node.metadata
    plate = self.metadata.get('metadata', {}).get('plate')
    image_attrs = image_node.zarr.root_attrs
    if plate is not None and image_attrs.get('version') == '0.6':
        # plate node metadata is taken from the first image of the first well
        well_path = plate['wells'][0]['path']
        well_attrs = self._get_reader(well_path)[0].zarr.root_attrs
        image_path = well_path + '/' + well_attrs['well']['images'][0]['path']
        image_attrs = self._get_reader(image_path)[0].zarr.root_attrs
    self.metadata = self._convert_metadata_from_v06(self.metadata, image_attrs)
    # channel metadata from ome-zarr-py limited; get from root_attrs manually
    #self.root_metadata = reader.zarr.root_attrs

    axes = self.metadata.get('axes', [])
    self.dim_order = ''.join([axis.get('name') for axis in axes])
    units = {axis['name']: axis['unit'] for axis in axes if 'unit' in axis}
    self.plate = self.metadata.get('metadata', {}).get('plate')
    self.is_plate = self.plate is not None

    scales = [transform['scale'] for transform_set in self.metadata['coordinateTransformations']
              for transform in transform_set if transform['type'] == 'scale']
    self.pixel_size = {dim: convert_to_um(pixel_size, units.get(dim, '')) for dim, pixel_size
                       in zip(self.dim_order, scales[0]) if dim in 'xyz'}
    x_index, y_index = self.dim_order.index('x'), self.dim_order.index('y')
    scale0 = np.mean([scales[0][x_index] + scales[0][y_index]])
    self.scales = [float(scale0 / np.mean([scale[x_index] + scale[y_index]])) for scale in scales]
    if self.is_plate:
        self.name = self.plate.get('name', '')
        self.rows = [row['name'] for row in self.plate.get('rows', [])]
        self.columns = [column['name'] for column in self.plate.get('columns', [])]
        self.wells = {well['path'].replace('/', ''): well['path'] for well in self.plate.get('wells')}
        self.fields = list(range(self.plate.get('field_count', 0)))
        self.paths = {well_id: {field: f'{well_path}/{field}' for field in self.fields} for well_id, well_path in self.wells.items()}
        self.acquisitions = self.plate.get('acquisitions', [])
        self.acquisition_datetime = datetime.fromisoformat(self.acquisitions[0]['date_created'])
        self.data = None    # data will be read per plate well
    else:
        self.name = self.metadata.get('name', '')
        # from the OME-XML below if it has one; not the file's time, which changes as the file is copied
        self.acquisition_datetime = None
        self.data = image_node.data
    if not self.name:
        self.name = get_filetitle(self.uri)
    self.name = str(self.name).removesuffix('.ome')

    self.shapes = [data.shape for data in image_node.data]
    self.shape = self.shapes[0]
    self.heights = [shape[y_index] for shape in self.shapes]
    self.widths = [shape[x_index] for shape in self.shapes]
    self.dtype = image_node.data[0].dtype
    self.bits_per_pixel = self.dtype.itemsize * 8

    self.channels = []
    colormaps = self.metadata.get('colormap', [])
    for channeli, channel_name in enumerate(self.metadata.get('channel_names', [])):
        channel = {'label': channel_name}
        if channeli < len(colormaps):
            channel['color'] = colormaps[channeli][-1]
        self.channels.append(channel)

    ome_xml_path = image_node.zarr.subpath(os.path.join(OME_DIR, OME_FILE))
    if os.path.exists(ome_xml_path):
        ome_xml_metadata = open(ome_xml_path, encoding='utf-8').read()
        ome_metadata = metadata_to_dict(ome_xml_metadata)
        (name, is_plate, pixel_size, position, dtype, bits_per_pixel, channels, microscope_info,
         acquisition_datetime,
         wells, rows, columns, fields, image_refs) = read_ome_xml_metadata(ome_metadata)
        for channel, ome_channel in zip(self.channels, channels):
            for key, value in ome_channel.items():
                if key not in channel:
                    channel[key] = value
        self.microscope_info = microscope_info
        if self.acquisition_datetime is None:
            self.acquisition_datetime = parse_datetime(acquisition_datetime)

    return self.metadata

is_rgb()

Source code in src/biomero_converter/OmeZarrSource.py
213
214
215
def is_rgb(self):
    # OME-Zarr has no RGB flag: 8-bit 3/4-channel images are taken as RGB(A), not e.g. 16-bit fluorescence
    return self.get_nchannels() in (3, 4) and self.get_dtype() == np.uint8

is_screen()

Source code in src/biomero_converter/OmeZarrSource.py
131
132
def is_screen(self):
    return self.is_plate

convert_multiscales_from_v06(multiscales)

Convert OME-Zarr 0.6 (RFC-5) multiscales metadata to the 0.4/0.5 layout (axes and a list of scale / translation transformations per dataset), so it can be read like earlier versions.

Parameters:

Name Type Description Default
multiscales list

Multiscales metadata.

required

Returns:

Name Type Description
list

Multiscales metadata in the 0.5 layout.

Source code in src/biomero_converter/ome_zarr_util.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def convert_multiscales_from_v06(multiscales):
    """
    Convert OME-Zarr 0.6 (RFC-5) multiscales metadata to the 0.4/0.5 layout (axes and a list of
    scale / translation transformations per dataset), so it can be read like earlier versions.

    Args:
        multiscales (list): Multiscales metadata.

    Returns:
        list: Multiscales metadata in the 0.5 layout.
    """
    converted = []
    for multiscale in multiscales:
        multiscale = dict(multiscale)
        coordinate_systems = multiscale.pop('coordinateSystems', None)
        if coordinate_systems is None:
            converted.append(multiscale)
            continue
        datasets = []
        for dataset in multiscale['datasets']:
            output = None
            transforms = []
            for transform in dataset['coordinateTransformations']:
                output = transform.get('output', {}).get('name', output)
                if transform['type'] == 'sequence':
                    transforms += transform['transformations']
                else:
                    transforms.append(transform)
            transforms = [{key: value for key, value in transform.items() if key not in ('input', 'output')}
                          for transform in transforms]
            datasets.append({**dataset, 'coordinateTransformations': transforms})
        # use the axes of the coordinate system the datasets map to
        names = [coordinate_system['name'] for coordinate_system in coordinate_systems]
        index = names.index(output) if output in names else 0
        multiscale['axes'] = coordinate_systems[index]['axes']
        multiscale['datasets'] = datasets
        converted.append(multiscale)
    return converted

convert_multiscales_to_v06(multiscales, coordinate_system='physical')

Convert OME-Zarr 0.4/0.5 multiscales metadata to the 0.6 (RFC-5) layout.

The axes move into a named coordinate system, and the transformations of each dataset become a single transformation (or a sequence of scale and translation) from the dataset path to that coordinate system.

Parameters:

Name Type Description Default
multiscales list

Multiscales metadata.

required
coordinate_system str

Name of the (physical) coordinate system.

'physical'

Returns:

Name Type Description
list

Multiscales metadata in the 0.6 layout.

Source code in src/biomero_converter/ome_zarr_util.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def convert_multiscales_to_v06(multiscales, coordinate_system='physical'):
    """
    Convert OME-Zarr 0.4/0.5 multiscales metadata to the 0.6 (RFC-5) layout.

    The axes move into a named coordinate system, and the transformations of each dataset become
    a single transformation (or a sequence of scale and translation) from the dataset path
    to that coordinate system.

    Args:
        multiscales (list): Multiscales metadata.
        coordinate_system (str): Name of the (physical) coordinate system.

    Returns:
        list: Multiscales metadata in the 0.6 layout.
    """
    converted = []
    for multiscale in multiscales:
        multiscale = dict(multiscale)
        multiscale['coordinateSystems'] = [{'name': coordinate_system, 'axes': multiscale.pop('axes')}]
        datasets = []
        for dataset in multiscale['datasets']:
            path = dataset['path']
            transforms = dataset['coordinateTransformations']
            if len(transforms) == 1:
                transform = dict(transforms[0])
            else:
                transform = {'type': 'sequence', 'transformations': transforms}
            transform['input'] = {'path': path}
            transform['output'] = {'name': coordinate_system}
            datasets.append({**dataset, 'coordinateTransformations': [transform]})
        multiscale['datasets'] = datasets
        converted.append(multiscale)
    return converted

create_axes_metadata(dimension_order)

Create axes metadata for OME-Zarr from dimension order.

Parameters:

Name Type Description Default
dimension_order str

String of dimension characters.

required

Returns:

Name Type Description
list

List of axis metadata dictionaries.

Source code in src/biomero_converter/ome_zarr_util.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def create_axes_metadata(dimension_order):
    """
    Create axes metadata for OME-Zarr from dimension order.

    Args:
        dimension_order (str): String of dimension characters.

    Returns:
        list: List of axis metadata dictionaries.
    """
    axes = []
    for dimension in dimension_order:
        unit1 = None
        if dimension == 't':
            type1 = 'time'
            unit1 = 'millisecond'
        elif dimension == 'c':
            type1 = 'channel'
        else:
            type1 = 'space'
            unit1 = 'micrometer'
        axis = {'name': dimension, 'type': type1}
        if unit1 is not None and unit1 != '':
            axis['unit'] = unit1
        axes.append(axis)
    return axes

create_channel_metadata(dtype, channels, nchannels, is_rgb, window, ome_version)

Create channel metadata for OME-Zarr.

Parameters:

Name Type Description Default
dtype

Numpy dtype of image data.

required
channels list

List of channel dicts.

required
nchannels int

Number of channels.

required
window tuple

Min/max window values.

required
ome_version str

OME-Zarr version.

required

Returns:

Name Type Description
dict

Channel metadata dictionary.

Source code in src/biomero_converter/ome_zarr_util.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def create_channel_metadata(dtype, channels, nchannels, is_rgb, window, ome_version):
    """
    Create channel metadata for OME-Zarr.

    Args:
        dtype: Numpy dtype of image data.
        channels (list): List of channel dicts.
        nchannels (int): Number of channels.
        window (tuple): Min/max window values.
        ome_version (str): OME-Zarr version.

    Returns:
        dict: Channel metadata dictionary.
    """
    if len(channels) < nchannels:
        labels = []
        colors = []
        if is_rgb and nchannels in (3, 4):
            labels = ['Red', 'Green', 'Blue']
            colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1)]
        if is_rgb and nchannels == 4:
            labels += ['Alpha']
            colors += [(1, 1, 1)]
        channels = [{'label': label, 'color': color} for label, color in zip(labels, colors)]

    omezarr_channels = []
    starts, ends = window
    for channeli, channel in enumerate(channels):
        omezarr_channel = {'label': channel.get('label', channel.get('Name', f'{channeli}')), 'active': True}
        color = channel.get('color', channel.get('Color'))
        # color is required in the omero metadata; default to white
        omezarr_channel['color'] = rgba_to_hexrgb(color) if color is not None else 'FFFFFF'
        if np.dtype(dtype).kind == 'f':
            min, max = 0, 1
        else:
            info = np.iinfo(dtype)
            min, max = info.min, info.max
        if len(starts) > 0 and len(ends) > 0:
            start, end = starts[channeli], ends[channeli]
        else:
            start, end = min, max
        omezarr_channel['window'] = {'min': min, 'max': max, 'start': start, 'end': end}
        omezarr_channels.append(omezarr_channel)

    metadata = {
        'version': ome_version,
        'channels': omezarr_channels,
    }
    return metadata

create_transformation_metadata(dimension_order, pixel_size_um, factor, translation_um=None)

Create transformation metadata (scale and translation) for OME-Zarr.

Parameters:

Name Type Description Default
dimension_order str

String of dimension characters.

required
pixel_size_um dict

Pixel size in micrometers per dimension.

required
factor float

Scaling factor.

required
translation_um dict

Translation in micrometers per dimension.

None

Returns:

Name Type Description
list

List of transformation metadata dictionaries.

Source code in src/biomero_converter/ome_zarr_util.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def create_transformation_metadata(dimension_order, pixel_size_um, factor, translation_um=None):
    """
    Create transformation metadata (scale and translation) for OME-Zarr.

    Args:
        dimension_order (str): String of dimension characters.
        pixel_size_um (dict): Pixel size in micrometers per dimension.
        factor (float): Scaling factor.
        translation_um (dict, optional): Translation in micrometers per dimension.

    Returns:
        list: List of transformation metadata dictionaries.
    """
    metadata = []
    pixel_size_scale = []
    translation_scale = []
    for dim in dimension_order:
        pixel_size_scale1 = pixel_size_um.get(dim, 1)
        if dim in 'xy':
            pixel_size_scale1 *= factor
        if pixel_size_scale1 == 0:
            pixel_size_scale1 = 1
        pixel_size_scale.append(pixel_size_scale1)

        if translation_um is not None:
            translation1 = translation_um.get(dim, 0)
            # translation_pyramid = translation + (scale - 1) * pixel_size / 2
            if dim in 'xy':
                translation1 += (factor - 1) * pixel_size_um[dim] / 2
            translation_scale.append(translation1)

    metadata.append({'type': 'scale', 'scale': pixel_size_scale})
    if translation_um is not None:
        metadata.append({'type': 'translation', 'translation': translation_scale})
    return metadata

get_version()

Get the version from the GitHub release tag.

Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image), otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag), otherwise the version of the installed package (taken from the tag when it was built).

Returns:

Name Type Description
str

Version string, or 'unknown' if it cannot be determined.

Source code in src/biomero_converter/version.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def get_version():
    """
    Get the version from the GitHub release tag.

    Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image),
    otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag),
    otherwise the version of the installed package (taken from the tag when it was built).

    Returns:
        str: Version string, or 'unknown' if it cannot be determined.
    """
    version = os.environ.get('BIOMERO_CONVERTER_VERSION')
    if version:
        return version
    # only this repo's own checkout: an installed package may sit inside another git repo
    if os.path.exists(os.path.join(REPO_DIR, '.git')):
        try:
            return subprocess.run(['git', 'describe', '--tags', '--always'], cwd=REPO_DIR,
                                  capture_output=True, text=True, check=True, timeout=10).stdout.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    try:
        return 'v' + package_version('biomero-converter')
    except PackageNotFoundError:
        return 'unknown'

patch_zarr_windows_network_move()

Work around zarr LocalStore failing on Windows network shares (see issue #10).

On Windows zarr uses os.rename for exclusive writes (set_if_not_exists) and relies on FileExistsError when the destination exists. Some network shares (SMB) report this as PermissionError [WinError 5] instead, which zarr does not handle.

Source code in src/biomero_converter/ome_zarr_util.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def patch_zarr_windows_network_move():
    """
    Work around zarr LocalStore failing on Windows network shares (see issue #10).

    On Windows zarr uses os.rename for exclusive writes (set_if_not_exists) and relies on
    FileExistsError when the destination exists. Some network shares (SMB) report this as
    PermissionError [WinError 5] instead, which zarr does not handle.
    """
    if sys.platform != 'win32':
        return
    import zarr.storage._local as zarr_local
    if getattr(zarr_local._safe_move, '_patched', False):
        return

    def _safe_move(src, dst):
        try:
            os.rename(src, dst)
        except PermissionError as e:
            if os.path.exists(dst):
                raise FileExistsError(*e.args) from e
            raise

    _safe_move._patched = True
    zarr_local._safe_move = _safe_move

rgba_to_hexrgb(rgba)

Source code in src/biomero_converter/color_conversion.py
16
17
18
def rgba_to_hexrgb(rgba: list) -> str:
    hexrgb = ''.join([hex(int(x * 255))[2:].upper().zfill(2) for x in rgba[:3]])
    return hexrgb

scale_dimensions_dict(shape0, scale)

Scale x and y dimensions in a shape dictionary.

Parameters:

Name Type Description Default
shape0 dict

Original shape dictionary.

required
scale float

Scaling factor.

required

Returns:

Name Type Description
dict

Scaled shape dictionary.

Source code in src/biomero_converter/ome_zarr_util.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def scale_dimensions_dict(shape0, scale):
    """
    Scale x and y dimensions in a shape dictionary.

    Args:
        shape0 (dict): Original shape dictionary.
        scale (float): Scaling factor.

    Returns:
        dict: Scaled shape dictionary.
    """
    shape = {}
    if scale == 1:
        return shape0
    for dimension, shape1 in shape0.items():
        if dimension[0] in ['x', 'y']:
            shape1 = int(shape1 * scale)
        shape[dimension] = shape1
    return shape

scale_dimensions_xy(shape0, dimension_order, scale)

Scale x and y dimensions in a shape tuple.

Parameters:

Name Type Description Default
shape0 tuple

Original shape.

required
dimension_order str

String of dimension characters.

required
scale float

Scaling factor.

required

Returns:

Name Type Description
list

Scaled shape.

Source code in src/biomero_converter/ome_zarr_util.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def scale_dimensions_xy(shape0, dimension_order, scale):
    """
    Scale x and y dimensions in a shape tuple.

    Args:
        shape0 (tuple): Original shape.
        dimension_order (str): String of dimension characters.
        scale (float): Scaling factor.

    Returns:
        list: Scaled shape.
    """
    shape = []
    if scale == 1:
        return shape0
    for shape1, dimension in zip(shape0, dimension_order):
        if dimension[0] in ['x', 'y']:
            shape1 = int(shape1 * scale)
        shape.append(shape1)
    return shape

OmeZarrWriter

OME_DIR = 'OME' module-attribute

OME_FILE = 'METADATA.ome.xml' module-attribute

PYRAMID_DOWNSCALE = 2 module-attribute

PYRAMID_LEVELS = 6 module-attribute

RETRY_ATTEMPTS = 3 module-attribute

TIFF_COMPRESSION = 'LZW' module-attribute

TILE_SIZE = 1024 module-attribute

VERSION = get_version() module-attribute

ZARR_CHUNK_SIZE = TILE_SIZE module-attribute

ZARR_SHARD_MULTIPLIER = 10 module-attribute

OmeZarrWriter

Bases: OmeWriter

Writer for exporting image or screen data to OME-Zarr format. Supports both single images and high-content screening (HCS) plates.

Source code in src/biomero_converter/OmeZarrWriter.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
class OmeZarrWriter(OmeWriter):
    """
    Writer for exporting image or screen data to OME-Zarr format.
    Supports both single images and high-content screening (HCS) plates.
    """

    def __init__(self, ome_version='0.4', verbose=False):
        """
        Initialize the OmeZarrWriter.

        Args:
            ome_version (str): OME-Zarr metadata version ('0.4', '0.5' or '0.6').
            verbose (bool): If True, print additional information.
        """
        super().__init__()
        self.ome_version = ome_version
        if ome_version == '0.4':
            from ome_zarr.format import FormatV04
            self.zarr_format = 2
            self.ome_format = FormatV04()
        elif ome_version in ('0.5', '0.6'):
            # ome-zarr-py does not support 0.6 (yet): write 0.5, then convert the metadata to 0.6
            self.zarr_format = 3
            from ome_zarr.format import FormatV05
            self.ome_format = FormatV05()
        else:
            self.ome_format = None
        self.verbose = verbose
        self.dim_order = 'tczyx'

    def write(self, filepath, source, write_xml=True, write_rocrate=True, **kwargs):
        """
        Write the provided source data to an OME-Zarr file.

        Args:
            filepath (str): Output path for the Zarr file.
            source: source reader supporting required interface.
            **kwargs: Additional arguments (e.g. wells selection).

        Returns:
            dict: Containing output_path: str Output file path.
        """
        if source.is_screen():
            zarr_root, total_size = self._write_screen(filepath, source, **kwargs)
        else:
            zarr_root, total_size = self._write_image(filepath, source, **kwargs)

        if self.ome_version == '0.6':
            self._convert_to_v06(zarr_root)
        zarr_root.attrs['_creator'] = {'name': 'nl.biomero.OmeZarrWriter', 'version': VERSION}

        if write_xml:
            self._write_ome_xml(filepath, source, **kwargs)

        if write_rocrate:
            self._write_ro_crate(filepath, source, **kwargs)

        if self.verbose:
            print(f'Total data written: {print_hbytes(total_size)}')

        return {'output_path': filepath}

    def _write_screen(self, filepath, source, **kwargs):
        """
        Write a high-content screening (HCS) plate to OME-Zarr.

        Args:
            filepath (str): Output path for the Zarr file.
            source: source reader supporting required interface.
            **kwargs: Additional arguments (e.g., wells).

        Returns:
            tuple: (zarr_root, total_size) where zarr_root is the root group and total_size is bytes written.
        """
        #zarr_location = parse_url(filename, mode='w', fmt=self.ome_format)
        zarr_location = filepath
        zarr_root = zarr.open_group(zarr_location, mode='w', zarr_format=self.zarr_format)

        row_names = [chr(ord('A') + index) for index
                     in range(max([ord(row_name.upper()) - ord('A') for row_name in source.get_rows()]) + 1)]
        col_names = [str(index) for index
                     in range(1, max([int(col) for col in source.get_columns()]) + 1)]
        wells = kwargs.get('wells', source.get_wells())
        well_paths = ['/'.join(split_well_name(well)) for well in wells]
        fields = list(map(str, source.get_fields()))

        acquisitions = source.get_acquisitions()
        name = source.get_name()
        write_plate_metadata(zarr_root, row_names, col_names, well_paths,
                             name=name, field_count=len(fields), acquisitions=acquisitions,
                             fmt=self.ome_format)
        total_size = 0
        for well_id in wells:
            row, col = split_well_name(well_id)
            row_group = zarr_root.require_group(str(row))
            well_group = row_group.require_group(str(col))
            write_well_metadata(well_group, fields, fmt=self.ome_format)
            position = source.get_position_um(well_id)
            for field_id in fields:
                image_group = well_group.require_group(field_id)
                data = source.get_data(self.dim_order, well_id=well_id, field_id=field_id)
                window_scanner = WindowScanner()
                window = source.get_image_window(window_scanner, well_id=well_id, field_id=field_id, data=data)
                size = self._write_data(image_group, data, source, window, position=position)
                total_size += size

        return zarr_root, total_size

    def _write_image(self, filepath, source, **kwargs):
        """
        Write a single image to OME-Zarr.

        Args:
            filepath (str): Output path for the Zarr file.
            source: source reader for image data.
            **kwargs: Additional arguments.

        Returns:
            tuple: (zarr_root, size) where zarr_root is the root group and size is bytes written.
        """
        #zarr_location = parse_url(filename, mode='w', fmt=self.ome_format)
        zarr_location = filepath
        zarr_root = zarr.open_group(zarr_location, mode='w', zarr_format=self.zarr_format)

        nlevels = len(source.get_scales())
        size0 = np.prod(source.get_shape()) * source.get_dtype().itemsize
        available = psutil.virtual_memory().available

        pyramid_data = []
        scale = 1
        last_level = None
        if nlevels > 1:
            # load best matching levels for pyramid
            pyramid_levels = get_pyramid_levels(source.get_shape(), source.get_dim_order(),
                                                PYRAMID_LEVELS, PYRAMID_DOWNSCALE)
            for index in range(pyramid_levels + 1):
                level, rescale = get_level_from_scale(source.get_scales(), scale)
                if level != last_level:
                    if size0 < available:
                        data = np.asarray(source.get_data(self.dim_order, level=level))
                    else:
                        data = source.get_data_as_dask(self.dim_order, level=level)
                    last_level = level
                if index == 0:
                    shape0 = data.shape
                if rescale != 1:
                    shape = list(shape0)
                    shape[-2:] = np.multiply(shape0[-2:], scale).astype(int)
                    if isinstance(data, da.Array):
                        data = dask_utils.resize(data, shape)
                    else:
                        data = resize(data, shape, preserve_range=True).astype(data.dtype)
                pyramid_data.append(data)
                scale /= PYRAMID_DOWNSCALE
        else:
            # no source pyramids sizes available
            if size0 < available:
                pyramid_data = np.asarray(source.get_data(self.dim_order))
            else:
                pyramid_data = source.get_data_as_dask(self.dim_order)

        window_scanner = WindowScanner()
        window = source.get_image_window(window_scanner)
        size = self._write_data(zarr_root, pyramid_data, source, window, position=source.get_position_um())
        return zarr_root, size

    def _write_data(self, group, data, source, window, position=None):
        """
        Write image data and metadata to a Zarr group.

        Args:
            group: Zarr group to write into.
            data: Image data array.
            source: source reader.
            window: Image window information.
            position: Optional position information.

        Returns:
            int: Number of bytes written.
        """
        dim_order = self.dim_order
        dtype = source.get_dtype()
        channels = source.get_channels()
        nchannels = source.get_nchannels()
        is_rgb = source.is_rgb()

        axes = create_axes_metadata(dim_order)
        pixel_size_scales, scaler = self._create_scale_metadata(source, dim_order, position)
        metadata = {'method': scaler.method}
        if channels or is_rgb:
            metadata['omero'] = create_channel_metadata(dtype, channels, nchannels, is_rgb, window, self.ome_version)

        is_pyramid = isinstance(data, list)
        if is_pyramid:
            data0 = data[0]
        else:
            data0 = data
        storage_options = None
        if float(self.ome_version) >= 0.5:
            if not hasattr(data0, 'chunksize'):
                chunks = []
                shards = []
                for dim, n in zip(dim_order, data0.shape):
                    if dim in 'xy':
                        chunks += [ZARR_CHUNK_SIZE]
                        shards += [ZARR_CHUNK_SIZE * ZARR_SHARD_MULTIPLIER]
                    else:
                        chunks += [1]
                        shards += [1]
                storage_options = {'chunks': chunks, 'shards': shards}

        size = data0.size * data0.itemsize
        if is_pyramid:
            #images = [Image.fromarray(data1) for data1 in data]
            #ngff_zarr.from_ngff_zarr() # use this to see construction
            #axes1 = [Axis()]
            #datasets1 = [Dataset()]
            #coordinateTransformations1 = Transform()
            #metadata = Metadata(axes1, datasets1, coordinateTransformations1)
            #multiscales = Multiscales(images, metadata)
            #ngff_zarr.to_ngff_zarr(group, multiscales=multiscales)

            write_multiscale(pyramid=data, group=group, axes=axes, coordinate_transformations=pixel_size_scales,
                            fmt=self.ome_format, storage_options=storage_options,
                            name=source.get_name(), metadata=metadata)
        else:
            write_image(image=data, group=group, axes=axes, coordinate_transformations=pixel_size_scales,
                        scaler=scaler, fmt=self.ome_format, storage_options=storage_options,
                        name=source.get_name(), metadata=metadata)
        return size

    def _create_scale_metadata(self, source, dim_order, translation=None, scaler=None):
        """
        Create coordinate transformation metadata for multiscale images.

        Args:
            source: source reader.
            dim_order (str): Dimension order string.
            translation: Translation or position information.
            scaler: Optional Scaler object.

        Returns:
            tuple: (pixel_size_scales, scaler)
        """
        if scaler is None:
            pyramid_levels = get_pyramid_levels(source.get_shape(), source.get_dim_order(),
                                                PYRAMID_LEVELS, PYRAMID_DOWNSCALE)
            scaler = Scaler(downscale=PYRAMID_DOWNSCALE, max_layer=pyramid_levels)
        pixel_size_scales = []
        factor = 1
        for i in range(scaler.max_layer + 1):
            pixel_size_scales.append(
                create_transformation_metadata(dim_order, source.get_pixel_size_um(),
                                               factor, translation))
            factor *= scaler.downscale
        return pixel_size_scales, scaler

    def _convert_to_v06(self, group):
        """
        Convert the OME-Zarr 0.5 metadata of a group and its sub-groups to 0.6.

        Args:
            group: Zarr group to convert.
        """
        ome = group.attrs.get('ome')
        if ome is not None:
            ome = dict(ome)
            ome['version'] = self.ome_version
            if 'multiscales' in ome:
                ome['multiscales'] = convert_multiscales_to_v06(ome['multiscales'])
            group.attrs['ome'] = ome
        for _, subgroup in group.groups():
            self._convert_to_v06(subgroup)

    def _write_ome_xml(self, filepath, source, wells=None, **kwargs):
        """
        Write ome xml metadata to a Zarr file.

        Args:
            filepath: output file path.
            source: source reader.
            wells: Optional list of well names.
            **kwargs:
        """
        reset_ome_ids()
        path = os.path.join(filepath, OME_DIR)
        if not os.path.exists(path):
            os.makedirs(path)
        ome_xml_filename = os.path.join(path, OME_FILE)
        xml_metadata = create_metadata(source, wells=wells, metadata_only=True)
        with open(ome_xml_filename, 'wb') as file:
            file.write(xml_metadata.encode())

    def _write_ro_crate(self, filepath, source, wells=None, **kwargs):
        """
        Write RO-Crate metadata to a Zarr file.

        Args:
            filepath: output file path.
            source: source reader.
            wells: Optional list of well names.
            **kwargs:
        """
        create_ro_crate(source=source, dest_path=filepath)

dim_order = 'tczyx' instance-attribute

ome_format = FormatV04() instance-attribute

ome_version = ome_version instance-attribute

verbose = verbose instance-attribute

zarr_format = 2 instance-attribute

__init__(ome_version='0.4', verbose=False)

Initialize the OmeZarrWriter.

Parameters:

Name Type Description Default
ome_version str

OME-Zarr metadata version ('0.4', '0.5' or '0.6').

'0.4'
verbose bool

If True, print additional information.

False
Source code in src/biomero_converter/OmeZarrWriter.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def __init__(self, ome_version='0.4', verbose=False):
    """
    Initialize the OmeZarrWriter.

    Args:
        ome_version (str): OME-Zarr metadata version ('0.4', '0.5' or '0.6').
        verbose (bool): If True, print additional information.
    """
    super().__init__()
    self.ome_version = ome_version
    if ome_version == '0.4':
        from ome_zarr.format import FormatV04
        self.zarr_format = 2
        self.ome_format = FormatV04()
    elif ome_version in ('0.5', '0.6'):
        # ome-zarr-py does not support 0.6 (yet): write 0.5, then convert the metadata to 0.6
        self.zarr_format = 3
        from ome_zarr.format import FormatV05
        self.ome_format = FormatV05()
    else:
        self.ome_format = None
    self.verbose = verbose
    self.dim_order = 'tczyx'

write(filepath, source, write_xml=True, write_rocrate=True, **kwargs)

Write the provided source data to an OME-Zarr file.

Parameters:

Name Type Description Default
filepath str

Output path for the Zarr file.

required
source

source reader supporting required interface.

required
**kwargs

Additional arguments (e.g. wells selection).

{}

Returns:

Name Type Description
dict

Containing output_path: str Output file path.

Source code in src/biomero_converter/OmeZarrWriter.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def write(self, filepath, source, write_xml=True, write_rocrate=True, **kwargs):
    """
    Write the provided source data to an OME-Zarr file.

    Args:
        filepath (str): Output path for the Zarr file.
        source: source reader supporting required interface.
        **kwargs: Additional arguments (e.g. wells selection).

    Returns:
        dict: Containing output_path: str Output file path.
    """
    if source.is_screen():
        zarr_root, total_size = self._write_screen(filepath, source, **kwargs)
    else:
        zarr_root, total_size = self._write_image(filepath, source, **kwargs)

    if self.ome_version == '0.6':
        self._convert_to_v06(zarr_root)
    zarr_root.attrs['_creator'] = {'name': 'nl.biomero.OmeZarrWriter', 'version': VERSION}

    if write_xml:
        self._write_ome_xml(filepath, source, **kwargs)

    if write_rocrate:
        self._write_ro_crate(filepath, source, **kwargs)

    if self.verbose:
        print(f'Total data written: {print_hbytes(total_size)}')

    return {'output_path': filepath}

convert_multiscales_from_v06(multiscales)

Convert OME-Zarr 0.6 (RFC-5) multiscales metadata to the 0.4/0.5 layout (axes and a list of scale / translation transformations per dataset), so it can be read like earlier versions.

Parameters:

Name Type Description Default
multiscales list

Multiscales metadata.

required

Returns:

Name Type Description
list

Multiscales metadata in the 0.5 layout.

Source code in src/biomero_converter/ome_zarr_util.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def convert_multiscales_from_v06(multiscales):
    """
    Convert OME-Zarr 0.6 (RFC-5) multiscales metadata to the 0.4/0.5 layout (axes and a list of
    scale / translation transformations per dataset), so it can be read like earlier versions.

    Args:
        multiscales (list): Multiscales metadata.

    Returns:
        list: Multiscales metadata in the 0.5 layout.
    """
    converted = []
    for multiscale in multiscales:
        multiscale = dict(multiscale)
        coordinate_systems = multiscale.pop('coordinateSystems', None)
        if coordinate_systems is None:
            converted.append(multiscale)
            continue
        datasets = []
        for dataset in multiscale['datasets']:
            output = None
            transforms = []
            for transform in dataset['coordinateTransformations']:
                output = transform.get('output', {}).get('name', output)
                if transform['type'] == 'sequence':
                    transforms += transform['transformations']
                else:
                    transforms.append(transform)
            transforms = [{key: value for key, value in transform.items() if key not in ('input', 'output')}
                          for transform in transforms]
            datasets.append({**dataset, 'coordinateTransformations': transforms})
        # use the axes of the coordinate system the datasets map to
        names = [coordinate_system['name'] for coordinate_system in coordinate_systems]
        index = names.index(output) if output in names else 0
        multiscale['axes'] = coordinate_systems[index]['axes']
        multiscale['datasets'] = datasets
        converted.append(multiscale)
    return converted

convert_multiscales_to_v06(multiscales, coordinate_system='physical')

Convert OME-Zarr 0.4/0.5 multiscales metadata to the 0.6 (RFC-5) layout.

The axes move into a named coordinate system, and the transformations of each dataset become a single transformation (or a sequence of scale and translation) from the dataset path to that coordinate system.

Parameters:

Name Type Description Default
multiscales list

Multiscales metadata.

required
coordinate_system str

Name of the (physical) coordinate system.

'physical'

Returns:

Name Type Description
list

Multiscales metadata in the 0.6 layout.

Source code in src/biomero_converter/ome_zarr_util.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def convert_multiscales_to_v06(multiscales, coordinate_system='physical'):
    """
    Convert OME-Zarr 0.4/0.5 multiscales metadata to the 0.6 (RFC-5) layout.

    The axes move into a named coordinate system, and the transformations of each dataset become
    a single transformation (or a sequence of scale and translation) from the dataset path
    to that coordinate system.

    Args:
        multiscales (list): Multiscales metadata.
        coordinate_system (str): Name of the (physical) coordinate system.

    Returns:
        list: Multiscales metadata in the 0.6 layout.
    """
    converted = []
    for multiscale in multiscales:
        multiscale = dict(multiscale)
        multiscale['coordinateSystems'] = [{'name': coordinate_system, 'axes': multiscale.pop('axes')}]
        datasets = []
        for dataset in multiscale['datasets']:
            path = dataset['path']
            transforms = dataset['coordinateTransformations']
            if len(transforms) == 1:
                transform = dict(transforms[0])
            else:
                transform = {'type': 'sequence', 'transformations': transforms}
            transform['input'] = {'path': path}
            transform['output'] = {'name': coordinate_system}
            datasets.append({**dataset, 'coordinateTransformations': [transform]})
        multiscale['datasets'] = datasets
        converted.append(multiscale)
    return converted

create_axes_metadata(dimension_order)

Create axes metadata for OME-Zarr from dimension order.

Parameters:

Name Type Description Default
dimension_order str

String of dimension characters.

required

Returns:

Name Type Description
list

List of axis metadata dictionaries.

Source code in src/biomero_converter/ome_zarr_util.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def create_axes_metadata(dimension_order):
    """
    Create axes metadata for OME-Zarr from dimension order.

    Args:
        dimension_order (str): String of dimension characters.

    Returns:
        list: List of axis metadata dictionaries.
    """
    axes = []
    for dimension in dimension_order:
        unit1 = None
        if dimension == 't':
            type1 = 'time'
            unit1 = 'millisecond'
        elif dimension == 'c':
            type1 = 'channel'
        else:
            type1 = 'space'
            unit1 = 'micrometer'
        axis = {'name': dimension, 'type': type1}
        if unit1 is not None and unit1 != '':
            axis['unit'] = unit1
        axes.append(axis)
    return axes

create_channel_metadata(dtype, channels, nchannels, is_rgb, window, ome_version)

Create channel metadata for OME-Zarr.

Parameters:

Name Type Description Default
dtype

Numpy dtype of image data.

required
channels list

List of channel dicts.

required
nchannels int

Number of channels.

required
window tuple

Min/max window values.

required
ome_version str

OME-Zarr version.

required

Returns:

Name Type Description
dict

Channel metadata dictionary.

Source code in src/biomero_converter/ome_zarr_util.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def create_channel_metadata(dtype, channels, nchannels, is_rgb, window, ome_version):
    """
    Create channel metadata for OME-Zarr.

    Args:
        dtype: Numpy dtype of image data.
        channels (list): List of channel dicts.
        nchannels (int): Number of channels.
        window (tuple): Min/max window values.
        ome_version (str): OME-Zarr version.

    Returns:
        dict: Channel metadata dictionary.
    """
    if len(channels) < nchannels:
        labels = []
        colors = []
        if is_rgb and nchannels in (3, 4):
            labels = ['Red', 'Green', 'Blue']
            colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1)]
        if is_rgb and nchannels == 4:
            labels += ['Alpha']
            colors += [(1, 1, 1)]
        channels = [{'label': label, 'color': color} for label, color in zip(labels, colors)]

    omezarr_channels = []
    starts, ends = window
    for channeli, channel in enumerate(channels):
        omezarr_channel = {'label': channel.get('label', channel.get('Name', f'{channeli}')), 'active': True}
        color = channel.get('color', channel.get('Color'))
        # color is required in the omero metadata; default to white
        omezarr_channel['color'] = rgba_to_hexrgb(color) if color is not None else 'FFFFFF'
        if np.dtype(dtype).kind == 'f':
            min, max = 0, 1
        else:
            info = np.iinfo(dtype)
            min, max = info.min, info.max
        if len(starts) > 0 and len(ends) > 0:
            start, end = starts[channeli], ends[channeli]
        else:
            start, end = min, max
        omezarr_channel['window'] = {'min': min, 'max': max, 'start': start, 'end': end}
        omezarr_channels.append(omezarr_channel)

    metadata = {
        'version': ome_version,
        'channels': omezarr_channels,
    }
    return metadata

create_transformation_metadata(dimension_order, pixel_size_um, factor, translation_um=None)

Create transformation metadata (scale and translation) for OME-Zarr.

Parameters:

Name Type Description Default
dimension_order str

String of dimension characters.

required
pixel_size_um dict

Pixel size in micrometers per dimension.

required
factor float

Scaling factor.

required
translation_um dict

Translation in micrometers per dimension.

None

Returns:

Name Type Description
list

List of transformation metadata dictionaries.

Source code in src/biomero_converter/ome_zarr_util.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def create_transformation_metadata(dimension_order, pixel_size_um, factor, translation_um=None):
    """
    Create transformation metadata (scale and translation) for OME-Zarr.

    Args:
        dimension_order (str): String of dimension characters.
        pixel_size_um (dict): Pixel size in micrometers per dimension.
        factor (float): Scaling factor.
        translation_um (dict, optional): Translation in micrometers per dimension.

    Returns:
        list: List of transformation metadata dictionaries.
    """
    metadata = []
    pixel_size_scale = []
    translation_scale = []
    for dim in dimension_order:
        pixel_size_scale1 = pixel_size_um.get(dim, 1)
        if dim in 'xy':
            pixel_size_scale1 *= factor
        if pixel_size_scale1 == 0:
            pixel_size_scale1 = 1
        pixel_size_scale.append(pixel_size_scale1)

        if translation_um is not None:
            translation1 = translation_um.get(dim, 0)
            # translation_pyramid = translation + (scale - 1) * pixel_size / 2
            if dim in 'xy':
                translation1 += (factor - 1) * pixel_size_um[dim] / 2
            translation_scale.append(translation1)

    metadata.append({'type': 'scale', 'scale': pixel_size_scale})
    if translation_um is not None:
        metadata.append({'type': 'translation', 'translation': translation_scale})
    return metadata

get_version()

Get the version from the GitHub release tag.

Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image), otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag), otherwise the version of the installed package (taken from the tag when it was built).

Returns:

Name Type Description
str

Version string, or 'unknown' if it cannot be determined.

Source code in src/biomero_converter/version.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def get_version():
    """
    Get the version from the GitHub release tag.

    Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image),
    otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag),
    otherwise the version of the installed package (taken from the tag when it was built).

    Returns:
        str: Version string, or 'unknown' if it cannot be determined.
    """
    version = os.environ.get('BIOMERO_CONVERTER_VERSION')
    if version:
        return version
    # only this repo's own checkout: an installed package may sit inside another git repo
    if os.path.exists(os.path.join(REPO_DIR, '.git')):
        try:
            return subprocess.run(['git', 'describe', '--tags', '--always'], cwd=REPO_DIR,
                                  capture_output=True, text=True, check=True, timeout=10).stdout.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    try:
        return 'v' + package_version('biomero-converter')
    except PackageNotFoundError:
        return 'unknown'

patch_zarr_windows_network_move()

Work around zarr LocalStore failing on Windows network shares (see issue #10).

On Windows zarr uses os.rename for exclusive writes (set_if_not_exists) and relies on FileExistsError when the destination exists. Some network shares (SMB) report this as PermissionError [WinError 5] instead, which zarr does not handle.

Source code in src/biomero_converter/ome_zarr_util.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def patch_zarr_windows_network_move():
    """
    Work around zarr LocalStore failing on Windows network shares (see issue #10).

    On Windows zarr uses os.rename for exclusive writes (set_if_not_exists) and relies on
    FileExistsError when the destination exists. Some network shares (SMB) report this as
    PermissionError [WinError 5] instead, which zarr does not handle.
    """
    if sys.platform != 'win32':
        return
    import zarr.storage._local as zarr_local
    if getattr(zarr_local._safe_move, '_patched', False):
        return

    def _safe_move(src, dst):
        try:
            os.rename(src, dst)
        except PermissionError as e:
            if os.path.exists(dst):
                raise FileExistsError(*e.args) from e
            raise

    _safe_move._patched = True
    zarr_local._safe_move = _safe_move

rgba_to_hexrgb(rgba)

Source code in src/biomero_converter/color_conversion.py
16
17
18
def rgba_to_hexrgb(rgba: list) -> str:
    hexrgb = ''.join([hex(int(x * 255))[2:].upper().zfill(2) for x in rgba[:3]])
    return hexrgb

scale_dimensions_dict(shape0, scale)

Scale x and y dimensions in a shape dictionary.

Parameters:

Name Type Description Default
shape0 dict

Original shape dictionary.

required
scale float

Scaling factor.

required

Returns:

Name Type Description
dict

Scaled shape dictionary.

Source code in src/biomero_converter/ome_zarr_util.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def scale_dimensions_dict(shape0, scale):
    """
    Scale x and y dimensions in a shape dictionary.

    Args:
        shape0 (dict): Original shape dictionary.
        scale (float): Scaling factor.

    Returns:
        dict: Scaled shape dictionary.
    """
    shape = {}
    if scale == 1:
        return shape0
    for dimension, shape1 in shape0.items():
        if dimension[0] in ['x', 'y']:
            shape1 = int(shape1 * scale)
        shape[dimension] = shape1
    return shape

scale_dimensions_xy(shape0, dimension_order, scale)

Scale x and y dimensions in a shape tuple.

Parameters:

Name Type Description Default
shape0 tuple

Original shape.

required
dimension_order str

String of dimension characters.

required
scale float

Scaling factor.

required

Returns:

Name Type Description
list

Scaled shape.

Source code in src/biomero_converter/ome_zarr_util.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def scale_dimensions_xy(shape0, dimension_order, scale):
    """
    Scale x and y dimensions in a shape tuple.

    Args:
        shape0 (tuple): Original shape.
        dimension_order (str): String of dimension characters.
        scale (float): Scaling factor.

    Returns:
        list: Scaled shape.
    """
    shape = []
    if scale == 1:
        return shape0
    for shape1, dimension in zip(shape0, dimension_order):
        if dimension[0] in ['x', 'y']:
            shape1 = int(shape1 * scale)
        shape.append(shape1)
    return shape

TiffSource

OME_LAYOUT_KEYS = ('TiffData', 'BinData', 'BigEndian', 'Interleaved') module-attribute

TIFF_LAYOUT_TAGS = {'NewSubfileType', 'SubfileType', 'Compression', 'Predictor', 'PlanarConfiguration', 'FillOrder', 'StripOffsets', 'StripByteCounts', 'RowsPerStrip', 'TileWidth', 'TileLength', 'TileDepth', 'TileOffsets', 'TileByteCounts', 'SubIFDs', 'JPEGTables', 'JPEGProc', 'JPEGInterchangeFormat', 'JPEGInterchangeFormatLength', 'JPEGRestartInterval', 'JPEGLosslessPredictors', 'JPEGPointTransforms', 'JPEGQTables', 'JPEGDCTables', 'JPEGACTables', 'YCbCrCoefficients', 'YCbCrSubSampling', 'YCbCrPositioning', 'ReferenceBlackWhite', 'ExtraSamples', 'ColorMap'} module-attribute

TiffSource

Bases: ImageSource

Loads image and metadata from TIFF or OME-TIFF files.

Source code in src/biomero_converter/TiffSource.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
class TiffSource(ImageSource):
    """
    Loads image and metadata from TIFF or OME-TIFF files.
    """
    def __init__(self, uri, metadata={}):
        """
        Initialize TiffSource.

        Args:
            uri (str): Path to the TIFF file.
            metadata (dict): Optional metadata dictionary.
        """
        super().__init__(uri, metadata)
        image_filename = None
        ext = os.path.splitext(uri)[1].lower()
        self.is_ome = False
        if 'tif' in ext:
            image_filename = uri
        elif ext in ('.ome', '.xml'):
            # read metadata
            with open(uri, 'rb') as file:
                self.metadata = metadata_to_dict(file.read().decode())
            self.is_ome = True
            # try to open linked ome-tiff file
            self.image_filenames = {}
            for image in ensure_list(self.metadata.get('Image', {})):
                filename = image.get('Pixels', {}).get('TiffData', {}).get('UUID', {}).get('FileName')
                if filename:
                    filepath = os.path.join(os.path.dirname(uri), filename)
                    self.image_filenames[image['ID']] = filepath
                    if image_filename is None:
                        image_filename = filepath
        else:
            raise RuntimeError(f'Unsupported tiff extension: {ext}')

        if image_filename:
            self.tiff = TiffFile(image_filename)
        else:
            self.tiff = None

    def init_metadata(self):
        acquisition_datetime = None
        acquisition_end_datetime = None
        pixel_size = {}
        position = {}
        rotation = None
        channels = []
        acquisition_metadata = {}
        wells = {}
        rows = []
        columns = []
        fields = []
        image_refs = {}
        metadata = {}

        if self.tiff:
            self.is_ome = self.tiff.is_ome
            self.is_imagej = self.tiff.is_imagej

            if self.tiff.series:
                page = self.tiff.series[0]
            else:
                page = self.tiff.pages.first
            # pyramid levels of the first series only (other series are e.g. thumbnails), as read in get_data()
            pages = page.levels if hasattr(page, 'levels') else [page]
            self.shapes = [page.shape for page in pages]
            self.shape = page.shape
            self.dim_order = page.axes.lower().replace('s', 'c').replace('r', '')
            x_index, y_index = self.dim_order.index('x'), self.dim_order.index('y')
            self.scales = [float(np.mean([shape[x_index] / self.shape[x_index], shape[y_index] / self.shape[y_index]]))
                           for shape in self.shapes]
            self.is_photometric_rgb = (self.tiff.pages.first.photometric == PHOTOMETRIC.RGB)
            self.nchannels = self.shape[self.dim_order.index('c')] if 'c' in self.dim_order else 1

        source_metadata = {}
        if self.is_ome:
            if self.tiff:
                metadata = metadata_to_dict(self.tiff.ome_metadata)
                # the OME document whole, but where its pixel data lies and how it is written
                source_metadata = without_keys(get_extra_metadata(self.tiff), OME_LAYOUT_KEYS)
            if metadata and not 'BinaryOnly' in metadata:
                self.metadata = metadata
            (name, is_plate, pixel_size, position, dtype, bits_per_pixel, channels, acquisition_metadata, acquisition_datetime,
             wells, rows, columns, fields, image_refs) = read_ome_xml_metadata(self.metadata)
            acquisition_end_datetime = read_ome_acquisition_end_datetime(self.metadata)
        else:
            is_plate = False
            if self.is_imagej:
                metadata = self.tiff.imagej_metadata
                pixel_size = get_fiji_pixelsize(metadata)

            metadata |= {key: value for page in self.tiff.pages for key, value in tags_to_dict(page.tags).items()
                         if key not in ('StripOffsets', 'StripByteCounts', 'TileOffsets', 'TileByteCounts', 'JPEGTables')}

            # vendor metadata in generic form, with the pixel size and position found in it
            acquisition_metadata = get_extra_metadata(self.tiff)
            # and beside it the baseline tags of the image, but those that only lay out or encode its pixels
            source_metadata = dict(acquisition_metadata)
            for tag in self.tiff.pages.first.tags.values():
                if tag.code < PRIVATE_TAG_CODE and tag.name not in TIFF_LAYOUT_TAGS:
                    source_metadata.setdefault(tag.name, plain_tag_value(tag.value))
            shape = dict(zip(self.dim_order, self.shape))
            for dim, size in get_pixel_size_um(self.tiff, acquisition_metadata, shape).items():
                pixel_size.setdefault(dim, size)
            position = get_position_um(acquisition_metadata)

            self.metadata = metadata
            name = self.tiff.filename
            # not the file's time: that changes as the file is copied
            if not acquisition_datetime and 'DateTime' in self.metadata:
                acquisition_datetime = parse_tiff_datetime(self.metadata['DateTime'])
            dtype = page.dtype
            bits_per_pixel = dtype.itemsize * 8

        if not name:
            name = get_filetitle(self.uri)
        self.name = os.path.splitext(str(name))[0].removesuffix('.ome')
        self.acquisition_datetime = acquisition_datetime
        self.acquisition_end_datetime = acquisition_end_datetime
        self.is_plate = is_plate
        self.wells = wells
        self.rows = rows
        self.columns = columns
        self.fields = fields
        self.image_refs = image_refs
        self.pixel_size = pixel_size
        self.position = position
        self.rotation = rotation
        self.channels = channels
        self.dtype = dtype
        self.bits_per_pixel = bits_per_pixel
        self.acquisition_metadata = acquisition_metadata
        self.source_metadata = source_metadata
        return self.metadata

    def is_screen(self):
        return self.is_plate

    def get_shape(self):
        return self.shape

    def get_shapes(self):
        return self.shapes

    def get_scales(self):
        return self.scales

    def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
        if well_id is not None:
            image_id = self.image_refs[well_id][int(field_id)]
            tiff = TiffFile(self.image_filenames[image_id])
        else:
            tiff = self.tiff
        data = tiff.asarray(level=level)
        return redimension_data(data, self.dim_order, dim_order)

    def get_data_as_dask(self, dim_order, level=0, **kwargs):
        #lazy_array = dask.delayed(imread)(self.uri, level=level)
        #data = da.from_delayed(lazy_array, shape=self.shapes[level], dtype=self.dtype)
        data = da.from_zarr(imread(self.uri, level=level, aszarr=True))
        if data.chunksize == data.shape:
            data = data.rechunk(TILE_SIZE)
        return redimension_data(data, self.dim_order, dim_order)

    def get_name(self):
        return self.name

    def get_dim_order(self):
        return self.dim_order

    def get_dtype(self):
        return self.dtype

    def get_pixel_size_um(self):
        if self.pixel_size:
            return self.pixel_size
        else:
            return {'x': 1, 'y': 1}

    def get_position_um(self, well_id=None):
        return self.position

    def get_channels(self):
        return self.channels

    def get_nchannels(self):
        return self.nchannels

    def is_rgb(self):
        return self.is_photometric_rgb

    def get_rows(self):
        return self.rows

    def get_columns(self):
        return self.columns

    def get_wells(self):
        return self.wells

    def get_time_points(self):
        nt = 1
        if 't' in self.dim_order:
            t_index = self.dim_order.index('t')
            nt = self.tiff.pages.first.shape[t_index]
        return list(range(nt))

    def get_fields(self):
        return self.fields

    def get_acquisitions(self):
        return []

    def get_acquisition_datetime(self):
        if self.is_ome:
            return self.acquisition_datetime
        # the vendor's acquisition date before the DateTime tag, which is when the file was written
        return self.get_model_acquisition_datetime() or self.acquisition_datetime

    def get_acquisition_end_datetime(self):
        return self.acquisition_end_datetime

    def get_significant_bits(self):
        return self.bits_per_pixel

    def get_acquisition_metadata(self):
        return self.acquisition_metadata

    def get_source_metadata(self):
        return self.source_metadata

    def close(self):
        self.tiff.close()

image_filenames = {} instance-attribute

is_ome = False instance-attribute

metadata = metadata_to_dict(file.read().decode()) instance-attribute

tiff = TiffFile(image_filename) instance-attribute

__init__(uri, metadata={})

Initialize TiffSource.

Parameters:

Name Type Description Default
uri str

Path to the TIFF file.

required
metadata dict

Optional metadata dictionary.

{}
Source code in src/biomero_converter/TiffSource.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(self, uri, metadata={}):
    """
    Initialize TiffSource.

    Args:
        uri (str): Path to the TIFF file.
        metadata (dict): Optional metadata dictionary.
    """
    super().__init__(uri, metadata)
    image_filename = None
    ext = os.path.splitext(uri)[1].lower()
    self.is_ome = False
    if 'tif' in ext:
        image_filename = uri
    elif ext in ('.ome', '.xml'):
        # read metadata
        with open(uri, 'rb') as file:
            self.metadata = metadata_to_dict(file.read().decode())
        self.is_ome = True
        # try to open linked ome-tiff file
        self.image_filenames = {}
        for image in ensure_list(self.metadata.get('Image', {})):
            filename = image.get('Pixels', {}).get('TiffData', {}).get('UUID', {}).get('FileName')
            if filename:
                filepath = os.path.join(os.path.dirname(uri), filename)
                self.image_filenames[image['ID']] = filepath
                if image_filename is None:
                    image_filename = filepath
    else:
        raise RuntimeError(f'Unsupported tiff extension: {ext}')

    if image_filename:
        self.tiff = TiffFile(image_filename)
    else:
        self.tiff = None

close()

Source code in src/biomero_converter/TiffSource.py
247
248
def close(self):
    self.tiff.close()

get_acquisition_datetime()

Source code in src/biomero_converter/TiffSource.py
229
230
231
232
233
def get_acquisition_datetime(self):
    if self.is_ome:
        return self.acquisition_datetime
    # the vendor's acquisition date before the DateTime tag, which is when the file was written
    return self.get_model_acquisition_datetime() or self.acquisition_datetime

get_acquisition_end_datetime()

Source code in src/biomero_converter/TiffSource.py
235
236
def get_acquisition_end_datetime(self):
    return self.acquisition_end_datetime

get_acquisition_metadata()

Source code in src/biomero_converter/TiffSource.py
241
242
def get_acquisition_metadata(self):
    return self.acquisition_metadata

get_acquisitions()

Source code in src/biomero_converter/TiffSource.py
226
227
def get_acquisitions(self):
    return []

get_channels()

Source code in src/biomero_converter/TiffSource.py
198
199
def get_channels(self):
    return self.channels

get_columns()

Source code in src/biomero_converter/TiffSource.py
210
211
def get_columns(self):
    return self.columns

get_data(dim_order, level=0, well_id=None, field_id=None, **kwargs)

Source code in src/biomero_converter/TiffSource.py
163
164
165
166
167
168
169
170
def get_data(self, dim_order, level=0, well_id=None, field_id=None, **kwargs):
    if well_id is not None:
        image_id = self.image_refs[well_id][int(field_id)]
        tiff = TiffFile(self.image_filenames[image_id])
    else:
        tiff = self.tiff
    data = tiff.asarray(level=level)
    return redimension_data(data, self.dim_order, dim_order)

get_data_as_dask(dim_order, level=0, **kwargs)

Source code in src/biomero_converter/TiffSource.py
172
173
174
175
176
177
178
def get_data_as_dask(self, dim_order, level=0, **kwargs):
    #lazy_array = dask.delayed(imread)(self.uri, level=level)
    #data = da.from_delayed(lazy_array, shape=self.shapes[level], dtype=self.dtype)
    data = da.from_zarr(imread(self.uri, level=level, aszarr=True))
    if data.chunksize == data.shape:
        data = data.rechunk(TILE_SIZE)
    return redimension_data(data, self.dim_order, dim_order)

get_dim_order()

Source code in src/biomero_converter/TiffSource.py
183
184
def get_dim_order(self):
    return self.dim_order

get_dtype()

Source code in src/biomero_converter/TiffSource.py
186
187
def get_dtype(self):
    return self.dtype

get_fields()

Source code in src/biomero_converter/TiffSource.py
223
224
def get_fields(self):
    return self.fields

get_name()

Source code in src/biomero_converter/TiffSource.py
180
181
def get_name(self):
    return self.name

get_nchannels()

Source code in src/biomero_converter/TiffSource.py
201
202
def get_nchannels(self):
    return self.nchannels

get_pixel_size_um()

Source code in src/biomero_converter/TiffSource.py
189
190
191
192
193
def get_pixel_size_um(self):
    if self.pixel_size:
        return self.pixel_size
    else:
        return {'x': 1, 'y': 1}

get_position_um(well_id=None)

Source code in src/biomero_converter/TiffSource.py
195
196
def get_position_um(self, well_id=None):
    return self.position

get_rows()

Source code in src/biomero_converter/TiffSource.py
207
208
def get_rows(self):
    return self.rows

get_scales()

Source code in src/biomero_converter/TiffSource.py
160
161
def get_scales(self):
    return self.scales

get_shape()

Source code in src/biomero_converter/TiffSource.py
154
155
def get_shape(self):
    return self.shape

get_shapes()

Source code in src/biomero_converter/TiffSource.py
157
158
def get_shapes(self):
    return self.shapes

get_significant_bits()

Source code in src/biomero_converter/TiffSource.py
238
239
def get_significant_bits(self):
    return self.bits_per_pixel

get_source_metadata()

Source code in src/biomero_converter/TiffSource.py
244
245
def get_source_metadata(self):
    return self.source_metadata

get_time_points()

Source code in src/biomero_converter/TiffSource.py
216
217
218
219
220
221
def get_time_points(self):
    nt = 1
    if 't' in self.dim_order:
        t_index = self.dim_order.index('t')
        nt = self.tiff.pages.first.shape[t_index]
    return list(range(nt))

get_wells()

Source code in src/biomero_converter/TiffSource.py
213
214
def get_wells(self):
    return self.wells

init_metadata()

Source code in src/biomero_converter/TiffSource.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def init_metadata(self):
    acquisition_datetime = None
    acquisition_end_datetime = None
    pixel_size = {}
    position = {}
    rotation = None
    channels = []
    acquisition_metadata = {}
    wells = {}
    rows = []
    columns = []
    fields = []
    image_refs = {}
    metadata = {}

    if self.tiff:
        self.is_ome = self.tiff.is_ome
        self.is_imagej = self.tiff.is_imagej

        if self.tiff.series:
            page = self.tiff.series[0]
        else:
            page = self.tiff.pages.first
        # pyramid levels of the first series only (other series are e.g. thumbnails), as read in get_data()
        pages = page.levels if hasattr(page, 'levels') else [page]
        self.shapes = [page.shape for page in pages]
        self.shape = page.shape
        self.dim_order = page.axes.lower().replace('s', 'c').replace('r', '')
        x_index, y_index = self.dim_order.index('x'), self.dim_order.index('y')
        self.scales = [float(np.mean([shape[x_index] / self.shape[x_index], shape[y_index] / self.shape[y_index]]))
                       for shape in self.shapes]
        self.is_photometric_rgb = (self.tiff.pages.first.photometric == PHOTOMETRIC.RGB)
        self.nchannels = self.shape[self.dim_order.index('c')] if 'c' in self.dim_order else 1

    source_metadata = {}
    if self.is_ome:
        if self.tiff:
            metadata = metadata_to_dict(self.tiff.ome_metadata)
            # the OME document whole, but where its pixel data lies and how it is written
            source_metadata = without_keys(get_extra_metadata(self.tiff), OME_LAYOUT_KEYS)
        if metadata and not 'BinaryOnly' in metadata:
            self.metadata = metadata
        (name, is_plate, pixel_size, position, dtype, bits_per_pixel, channels, acquisition_metadata, acquisition_datetime,
         wells, rows, columns, fields, image_refs) = read_ome_xml_metadata(self.metadata)
        acquisition_end_datetime = read_ome_acquisition_end_datetime(self.metadata)
    else:
        is_plate = False
        if self.is_imagej:
            metadata = self.tiff.imagej_metadata
            pixel_size = get_fiji_pixelsize(metadata)

        metadata |= {key: value for page in self.tiff.pages for key, value in tags_to_dict(page.tags).items()
                     if key not in ('StripOffsets', 'StripByteCounts', 'TileOffsets', 'TileByteCounts', 'JPEGTables')}

        # vendor metadata in generic form, with the pixel size and position found in it
        acquisition_metadata = get_extra_metadata(self.tiff)
        # and beside it the baseline tags of the image, but those that only lay out or encode its pixels
        source_metadata = dict(acquisition_metadata)
        for tag in self.tiff.pages.first.tags.values():
            if tag.code < PRIVATE_TAG_CODE and tag.name not in TIFF_LAYOUT_TAGS:
                source_metadata.setdefault(tag.name, plain_tag_value(tag.value))
        shape = dict(zip(self.dim_order, self.shape))
        for dim, size in get_pixel_size_um(self.tiff, acquisition_metadata, shape).items():
            pixel_size.setdefault(dim, size)
        position = get_position_um(acquisition_metadata)

        self.metadata = metadata
        name = self.tiff.filename
        # not the file's time: that changes as the file is copied
        if not acquisition_datetime and 'DateTime' in self.metadata:
            acquisition_datetime = parse_tiff_datetime(self.metadata['DateTime'])
        dtype = page.dtype
        bits_per_pixel = dtype.itemsize * 8

    if not name:
        name = get_filetitle(self.uri)
    self.name = os.path.splitext(str(name))[0].removesuffix('.ome')
    self.acquisition_datetime = acquisition_datetime
    self.acquisition_end_datetime = acquisition_end_datetime
    self.is_plate = is_plate
    self.wells = wells
    self.rows = rows
    self.columns = columns
    self.fields = fields
    self.image_refs = image_refs
    self.pixel_size = pixel_size
    self.position = position
    self.rotation = rotation
    self.channels = channels
    self.dtype = dtype
    self.bits_per_pixel = bits_per_pixel
    self.acquisition_metadata = acquisition_metadata
    self.source_metadata = source_metadata
    return self.metadata

is_rgb()

Source code in src/biomero_converter/TiffSource.py
204
205
def is_rgb(self):
    return self.is_photometric_rgb

is_screen()

Source code in src/biomero_converter/TiffSource.py
151
152
def is_screen(self):
    return self.is_plate

get_fiji_pixelsize(metadata)

Source code in src/biomero_converter/TiffSource.py
251
252
253
254
255
256
257
258
259
260
261
def get_fiji_pixelsize(metadata):
    pixel_size = {}
    pixel_size_unit = metadata.get('unit', '').encode().decode('unicode_escape')
    if 'scales' in metadata:
        for dim, scale in zip(['x', 'y'], metadata['scales'].split(',')):
            scale = scale.strip()
            if scale != '':
                pixel_size[dim] = convert_to_um(float(scale), pixel_size_unit)
    if 'spacing' in metadata:
        pixel_size['z'] = convert_to_um(metadata['spacing'], pixel_size_unit)
    return pixel_size

parse_tiff_datetime(value)

Parses a TIFF DateTime tag value.

Parameters:

Name Type Description Default
value str

'YYYY:MM:DD HH:MM:SS' as the TIFF spec defines, or another format some writers use.

required

Returns:

Name Type Description
datetime

Parsed datetime.

Source code in src/biomero_converter/TiffSource.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def parse_tiff_datetime(value):
    """
    Parses a TIFF DateTime tag value.

    Args:
        value (str): 'YYYY:MM:DD HH:MM:SS' as the TIFF spec defines, or another format some writers use.

    Returns:
        datetime: Parsed datetime.
    """
    try:
        return datetime.strptime(value.strip(), '%Y:%m:%d %H:%M:%S')
    except ValueError:
        # dateutil reads the TIFF format as a time of today, so only use it for other formats
        return dateutil.parser.parse(value)

plain_tag_value(value)

A tag's value as plain data: an enumeration by its name, text read as text.

Source code in src/biomero_converter/TiffSource.py
277
278
279
280
281
282
283
def plain_tag_value(value):
    """A tag's value as plain data: an enumeration by its name, text read as text."""
    if isinstance(value, Enum):
        return value.name
    if isinstance(value, bytes):
        return value.decode('utf-8', errors='replace')
    return value

tags_to_dict(tags)

Converts TIFF tags to a dictionary.

Parameters:

Name Type Description Default
tags

TIFF tags object.

required

Returns:

Name Type Description
dict

Tag name-value mapping.

Source code in src/biomero_converter/TiffSource.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
def tags_to_dict(tags):
    """
    Converts TIFF tags to a dictionary.

    Args:
        tags: TIFF tags object.

    Returns:
        dict: Tag name-value mapping.
    """
    tag_dict = {}
    for tag in tags.values():
        value = tag.value
        if isinstance(value, Enum):
            value = value.name
        tag_dict[tag.name] = value
    return tag_dict

Timer

Timer

Bases: object

Context manager for timing code execution and logging the elapsed time.

Source code in src/biomero_converter/Timer.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Timer(object):
    """
    Context manager for timing code execution and logging the elapsed time.
    """

    def __init__(self, title, auto_unit=True, verbose=True):
        """
        Initialize the Timer.

        Args:
            title (str): Description for the timed block.
            auto_unit (bool): Automatically select time unit (seconds/minutes/hours).
            verbose (bool): If True, log the elapsed time.
        """
        self.title = title
        self.auto_unit = auto_unit
        self.verbose = verbose

    def __enter__(self):
        """
        Start timing.
        """
        self.ptime_start = time.process_time()
        self.time_start = time.time()

    def __exit__(self, type, value, traceback):
        """
        Stop timing and log the elapsed time.

        Args:
            type: Exception type, if any.
            value: Exception value, if any.
            traceback: Exception traceback, if any.
        """
        if self.verbose:
            ptime_end = time.process_time()
            time_end = time.time()
            pelapsed = ptime_end - self.ptime_start
            elapsed = time_end - self.time_start
            unit = 'seconds'
            if self.auto_unit and elapsed >= 60:
                pelapsed /= 60
                elapsed /= 60
                unit = 'minutes'
                if elapsed >= 60:
                    pelapsed /= 60
                    elapsed /= 60
                    unit = 'hours'
            logging.info(f'Time {self.title}: {elapsed:.1f} ({pelapsed:.1f}) {unit}')

auto_unit = auto_unit instance-attribute

title = title instance-attribute

verbose = verbose instance-attribute

__enter__()

Start timing.

Source code in src/biomero_converter/Timer.py
25
26
27
28
29
30
def __enter__(self):
    """
    Start timing.
    """
    self.ptime_start = time.process_time()
    self.time_start = time.time()

__exit__(type, value, traceback)

Stop timing and log the elapsed time.

Parameters:

Name Type Description Default
type

Exception type, if any.

required
value

Exception value, if any.

required
traceback

Exception traceback, if any.

required
Source code in src/biomero_converter/Timer.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def __exit__(self, type, value, traceback):
    """
    Stop timing and log the elapsed time.

    Args:
        type: Exception type, if any.
        value: Exception value, if any.
        traceback: Exception traceback, if any.
    """
    if self.verbose:
        ptime_end = time.process_time()
        time_end = time.time()
        pelapsed = ptime_end - self.ptime_start
        elapsed = time_end - self.time_start
        unit = 'seconds'
        if self.auto_unit and elapsed >= 60:
            pelapsed /= 60
            elapsed /= 60
            unit = 'minutes'
            if elapsed >= 60:
                pelapsed /= 60
                elapsed /= 60
                unit = 'hours'
        logging.info(f'Time {self.title}: {elapsed:.1f} ({pelapsed:.1f}) {unit}')

__init__(title, auto_unit=True, verbose=True)

Initialize the Timer.

Parameters:

Name Type Description Default
title str

Description for the timed block.

required
auto_unit bool

Automatically select time unit (seconds/minutes/hours).

True
verbose bool

If True, log the elapsed time.

True
Source code in src/biomero_converter/Timer.py
12
13
14
15
16
17
18
19
20
21
22
23
def __init__(self, title, auto_unit=True, verbose=True):
    """
    Initialize the Timer.

    Args:
        title (str): Description for the timed block.
        auto_unit (bool): Automatically select time unit (seconds/minutes/hours).
        verbose (bool): If True, log the elapsed time.
    """
    self.title = title
    self.auto_unit = auto_unit
    self.verbose = verbose

TreeBuilder

TreeBuilder

Source code in src/biomero_converter/TreeBuilder.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TreeBuilder:
    def __init__(self, data):
        self.root = data
        self.index_ids()

    def index_ids(self):
        self.id_index = {item['@id']: item for item in self.root if isinstance(item, dict) and '@id' in item}

    def find_id(self, id_value):
        return self.id_index.get(id_value)

    def recreate_tree(self, data=None):
        if data is None:
            data = self.root

        if isinstance(data, dict) and len(data) == 1 and '@id' in data:
            data = self.find_id(data['@id'])

        if isinstance(data, dict):
            tree = {key: self.recreate_tree(value) for key, value in data.items()}
        elif isinstance(data, list):
            tree = [self.recreate_tree(item) for item in data]
        else:
            tree = data
        return tree

root = data instance-attribute

__init__(data)

Source code in src/biomero_converter/TreeBuilder.py
2
3
4
def __init__(self, data):
    self.root = data
    self.index_ids()

find_id(id_value)

Source code in src/biomero_converter/TreeBuilder.py
 9
10
def find_id(self, id_value):
    return self.id_index.get(id_value)

index_ids()

Source code in src/biomero_converter/TreeBuilder.py
6
7
def index_ids(self):
    self.id_index = {item['@id']: item for item in self.root if isinstance(item, dict) and '@id' in item}

recreate_tree(data=None)

Source code in src/biomero_converter/TreeBuilder.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def recreate_tree(self, data=None):
    if data is None:
        data = self.root

    if isinstance(data, dict) and len(data) == 1 and '@id' in data:
        data = self.find_id(data['@id'])

    if isinstance(data, dict):
        tree = {key: self.recreate_tree(value) for key, value in data.items()}
    elif isinstance(data, list):
        tree = [self.recreate_tree(item) for item in data]
    else:
        tree = data
    return tree

WindowScanner

WindowScanner

Computes quantile-based min/max window for image channels.

Source code in src/biomero_converter/WindowScanner.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class WindowScanner:
    """
    Computes quantile-based min/max window for image channels.
    """

    def __init__(self):
        """
        Initialize WindowScanner.
        """
        self.mins = []
        self.maxs = []

    def process(self, data, dim_order, min_quantile=0.01, max_quantile=0.99):
        """
        Processes image data to compute min/max quantiles for each channel.

        Args:
            data (ndarray): Image data.
            dim_order (str): Dimension order string.
            min_quantile (float): Lower quantile.
            max_quantile (float): Upper quantile.
        """
        axis = []
        if 't' in dim_order:
            axis += [dim_order.index('t')]
        if 'z' in dim_order:
            axis += [dim_order.index('z')]
        axis += [dim_order.index('y'), dim_order.index('x')]
        values = np.quantile(data, axis=axis, q=[min_quantile, max_quantile])
        mins, maxs = values
        if len(self.mins) == 0:
            self.mins = mins
            self.maxs = maxs
        else:
            self.mins = np.min([mins, self.mins], axis=0)
            self.maxs = np.max([maxs, self.maxs], axis=0)

    def get_window(self):
        """
        Returns the computed min/max window for channels.

        Returns:
            tuple: (min dict, max dict)
        """
        return ensure_list(np.array(self.mins).tolist()), ensure_list(np.array(self.maxs).tolist())

maxs = [] instance-attribute

mins = [] instance-attribute

__init__()

Initialize WindowScanner.

Source code in src/biomero_converter/WindowScanner.py
11
12
13
14
15
16
def __init__(self):
    """
    Initialize WindowScanner.
    """
    self.mins = []
    self.maxs = []

get_window()

Returns the computed min/max window for channels.

Returns:

Name Type Description
tuple

(min dict, max dict)

Source code in src/biomero_converter/WindowScanner.py
43
44
45
46
47
48
49
50
def get_window(self):
    """
    Returns the computed min/max window for channels.

    Returns:
        tuple: (min dict, max dict)
    """
    return ensure_list(np.array(self.mins).tolist()), ensure_list(np.array(self.maxs).tolist())

process(data, dim_order, min_quantile=0.01, max_quantile=0.99)

Processes image data to compute min/max quantiles for each channel.

Parameters:

Name Type Description Default
data ndarray

Image data.

required
dim_order str

Dimension order string.

required
min_quantile float

Lower quantile.

0.01
max_quantile float

Upper quantile.

0.99
Source code in src/biomero_converter/WindowScanner.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def process(self, data, dim_order, min_quantile=0.01, max_quantile=0.99):
    """
    Processes image data to compute min/max quantiles for each channel.

    Args:
        data (ndarray): Image data.
        dim_order (str): Dimension order string.
        min_quantile (float): Lower quantile.
        max_quantile (float): Upper quantile.
    """
    axis = []
    if 't' in dim_order:
        axis += [dim_order.index('t')]
    if 'z' in dim_order:
        axis += [dim_order.index('z')]
    axis += [dim_order.index('y'), dim_order.index('x')]
    values = np.quantile(data, axis=axis, q=[min_quantile, max_quantile])
    mins, maxs = values
    if len(self.mins) == 0:
        self.mins = mins
        self.maxs = maxs
    else:
        self.mins = np.min([mins, self.mins], axis=0)
        self.maxs = np.max([maxs, self.maxs], axis=0)

color_conversion

hexrgb_to_rgba(hexrgb)

Source code in src/biomero_converter/color_conversion.py
21
22
23
24
25
26
def hexrgb_to_rgba(hexrgb: str) -> list:
    hexrgb = hexrgb.lstrip('#')
    if len(hexrgb) == 6:
        hexrgb += 'FF'  # add alpha
    rgba = int_to_rgba(eval('0x' + hexrgb))
    return rgba

int_to_rgba(intrgba)

Source code in src/biomero_converter/color_conversion.py
3
4
5
6
7
8
def int_to_rgba(intrgba: int) -> list:
    signed = (intrgba < 0)
    rgba = [x / 255 for x in intrgba.to_bytes(4, signed=signed, byteorder="big")]
    if rgba[-1] == 0:
        rgba[-1] = 1
    return rgba

rgba_to_hexrgb(rgba)

Source code in src/biomero_converter/color_conversion.py
16
17
18
def rgba_to_hexrgb(rgba: list) -> str:
    hexrgb = ''.join([hex(int(x * 255))[2:].upper().zfill(2) for x in rgba[:3]])
    return hexrgb

rgba_to_int(rgba)

Source code in src/biomero_converter/color_conversion.py
11
12
13
def rgba_to_int(rgba: list) -> int:
    intrgba = int.from_bytes([int(x * 255) for x in rgba], signed=True, byteorder="big")
    return intrgba

converter

convert(input_filename, output_folder, alt_output_folder=None, output_format=None, show_progress=False, verbose=False, max_attempts=RETRY_ATTEMPTS, **kwargs)

Source code in src/biomero_converter/converter.py
33
34
35
36
37
38
39
40
41
42
43
44
45
def convert(input_filename, output_folder, alt_output_folder=None,
            output_format=None, show_progress=False, verbose=False, max_attempts=RETRY_ATTEMPTS, **kwargs):
    attempts = 0
    while True:
        try:
            return _convert(input_filename, output_folder, alt_output_folder=alt_output_folder,
                            output_format=output_format, show_progress=show_progress, verbose=verbose,
                            **kwargs)
        except Exception as e:
            if attempts >= max_attempts - 1:
                logging.error(e)
                raise Exception(f'Conversion failed after {max_attempts} attempts: {input_filename}')
        attempts += 1

init_logging(log_filename, verbose=False)

Initialize logging to file and optionally to console.

Parameters:

Name Type Description Default
log_filename str

Path to the log file.

required
verbose bool

If True, also log to console.

False
Source code in src/biomero_converter/converter.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def init_logging(log_filename, verbose=False):
    """
    Initialize logging to file and optionally to console.

    Args:
        log_filename (str): Path to the log file.
        verbose (bool): If True, also log to console.
    """
    basepath = os.path.dirname(log_filename)
    if basepath and not os.path.exists(basepath):
        os.makedirs(basepath)
    handlers = [logging.FileHandler(log_filename, encoding='utf-8')]
    if verbose:
        handlers += [logging.StreamHandler()]
    logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s',
                        handlers=handlers,
                        encoding='utf-8')

    logging.getLogger('ome_zarr').setLevel(logging.WARNING)     # mute verbose ome_zarr logging

helper

LEICA_EXTENSIONS = ['.lif', '.lof', '.lifext', '.xlif', '.xlcf', '.xlef'] module-attribute

create_incucyte_source(filename, plate_id=None)

Create an IncucyteSource object for a specific plate.

Parameters:

Name Type Description Default
filename str

Path to the Incucyte archive folder or .icarch file.

required
plate_id str

Specific plate ID to process. If None, uses the first available plate if multiple plates exist.

None

Returns:

Name Type Description
IncucyteSource

Source object for the specified plate.

Raises:

Type Description
ValueError

If the path is not a valid Incucyte archive.

Source code in src/biomero_converter/helper.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def create_incucyte_source(filename, plate_id=None):
    """
    Create an IncucyteSource object for a specific plate.

    Args:
        filename (str): Path to the Incucyte archive folder or .icarch file.
        plate_id (str, optional): Specific plate ID to process. If None,
                                 uses the first available plate if multiple
                                 plates exist.

    Returns:
        IncucyteSource: Source object for the specified plate.

    Raises:
        ValueError: If the path is not a valid Incucyte archive.
    """
    # If it's an .icarch file, use its parent folder
    if os.path.isfile(filename) and filename.lower().endswith('.icarch'):
        archive_folder = os.path.dirname(filename)
    elif os.path.isdir(filename):
        archive_folder = filename
    else:
        raise ValueError(
            f'Invalid Incucyte archive path. Expected folder or .icarch '
            f'file: {filename}'
        )

    from biomero_converter.IncucyteSource import IncucyteSource
    return IncucyteSource(archive_folder, plate_id=plate_id)

create_source(filename, **kwargs)

Create an image source object based on the input file extension.

Parameters:

Name Type Description Default
filename str

Path to the input file or Incucyte .icarch file.

required
**kwargs

Source-specific parameters (e.g., plate_id for Incucyte).

{}

Returns:

Name Type Description
ImageSource

Source object for the input file.

Raises:

Type Description
ValueError

If the file format is unsupported.

Source code in src/biomero_converter/helper.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def create_source(filename, **kwargs):
    """
    Create an image source object based on the input file extension.

    Args:
        filename (str): Path to the input file or Incucyte .icarch file.
        **kwargs: Source-specific parameters (e.g., plate_id for Incucyte).

    Returns:
        ImageSource: Source object for the input file.

    Raises:
        ValueError: If the file format is unsupported.
    """
    input_ext = os.path.splitext(filename)[1].lower()

    if input_ext in LEICA_EXTENSIONS:
        from biomero_converter.LeicaSource import LeicaSource
        source = LeicaSource(filename, **kwargs)
    elif input_ext == '.db':
        from biomero_converter.ImageDbSource import ImageDbSource
        source = ImageDbSource(filename)
    elif input_ext == '.icarch':
        # Incucyte archive file - use parent folder for source
        if not os.path.isfile(filename):
            raise ValueError(
                f'Incucyte archive file not found: {filename}'
            )
        archive_folder = os.path.dirname(filename)
        # Verify EssenFiles folder exists
        essen_path = os.path.join(archive_folder, 'EssenFiles')
        if not os.path.isdir(essen_path):
            raise ValueError(
                f'EssenFiles folder not found in: {archive_folder}. '
                f'Expected Incucyte archive structure.'
            )
        from biomero_converter.IncucyteSource import IncucyteSource
        # Pass kwargs to IncucyteSource (e.g., plate_id)
        source = IncucyteSource(archive_folder, **kwargs)
    elif input_ext == '.isyntax':
        from biomero_converter.ISyntaxSource import ISyntaxSource
        source = ISyntaxSource(filename, **kwargs)
    elif input_ext == '.mrxs':
        from biomero_converter.MiraxSource import MiraxSource
        source = MiraxSource(filename, **kwargs)
    elif '.zar' in input_ext:
        from biomero_converter.OmeZarrSource import OmeZarrSource
        source = OmeZarrSource(filename, **kwargs)
    elif '.tif' in input_ext or input_ext in ('.ome', '.xml'):
        from biomero_converter.TiffSource import TiffSource
        source = TiffSource(filename, **kwargs)
    elif is_dicom(filename):
        from biomero_converter.DicomSource import DicomSource
        source = DicomSource(filename, **kwargs)
    else:
        from biomero_converter.GenericSource import GenericSource
        source = GenericSource(filename, **kwargs)
    return source

create_writer(output_format, verbose=False)

Create a writer object and output extension based on the output format.

Parameters:

Name Type Description Default
output_format str

Output format string: 'omezarr04', 'omezarr05', 'omezarr06' (OME-Zarr version) or 'ometiff'. The legacy names 'omezarr2' and 'omezarr3' (Zarr version) are still accepted.

required
verbose bool

If True, enables verbose output.

False

Returns:

Name Type Description
tuple

(writer object, output file extension)

Raises:

Type Description
ValueError

If the output format is unsupported.

Source code in src/biomero_converter/helper.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def create_writer(output_format, verbose=False):
    """
    Create a writer object and output extension based on the output format.

    Args:
        output_format (str): Output format string: 'omezarr04', 'omezarr05', 'omezarr06' (OME-Zarr version) or 'ometiff'.
            The legacy names 'omezarr2' and 'omezarr3' (Zarr version) are still accepted.
        verbose (bool): If True, enables verbose output.

    Returns:
        tuple: (writer object, output file extension)

    Raises:
        ValueError: If the output format is unsupported.
    """
    if 'zar' in output_format:
        version = ''.join(c for c in output_format if c.isdigit())
        if version in ('04', '4', '2'):
            ome_version = '0.4'
        elif version in ('05', '5', '3'):
            ome_version = '0.5'
        elif version in ('06', '6'):
            ome_version = '0.6'
        else:
            raise ValueError(f'Unsupported output format: {output_format}')
        from biomero_converter.OmeZarrWriter import OmeZarrWriter
        writer = OmeZarrWriter(ome_version=ome_version, verbose=verbose)
        ext = '.ome.zarr'
    elif 'tif' in output_format:
        from biomero_converter.OmeTiffWriter import OmeTiffWriter
        writer = OmeTiffWriter(verbose=verbose)
        ext = '.ome.tiff'
    else:
        raise ValueError(f'Unsupported output format: {output_format}')
    return writer, ext

get_incucyte_plates(filename)

Get all available plate IDs from an Incucyte archive.

Parameters:

Name Type Description Default
filename str

Path to the Incucyte archive folder or .icarch file.

required

Returns:

Name Type Description
list

List of plate IDs (strings) found in the archive.

Raises:

Type Description
ValueError

If the path is not a valid Incucyte archive.

Source code in src/biomero_converter/helper.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def get_incucyte_plates(filename):
    """
    Get all available plate IDs from an Incucyte archive.

    Args:
        filename (str): Path to the Incucyte archive folder or .icarch file.

    Returns:
        list: List of plate IDs (strings) found in the archive.

    Raises:
        ValueError: If the path is not a valid Incucyte archive.
    """
    # If it's an .icarch file, use its parent folder
    if os.path.isfile(filename) and filename.lower().endswith('.icarch'):
        archive_folder = os.path.dirname(filename)
    elif os.path.isdir(filename):
        archive_folder = filename
    else:
        raise ValueError(
            f'Invalid Incucyte archive path. Expected folder or .icarch '
            f'file: {filename}'
        )

    from biomero_converter.IncucyteSource import IncucyteSource
    return IncucyteSource.get_available_plates(archive_folder)

is_dicom(filename)

Check if the input is a DICOM file (by extension or content), a DICOMDIR or a folder holding DICOM files.

Source code in src/biomero_converter/helper.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def is_dicom(filename):
    """
    Check if the input is a DICOM file (by extension or content), a DICOMDIR or a folder holding DICOM files.
    """
    if os.path.splitext(filename)[1].lower() in ['.dcm', '.dicom']:
        return True
    from pydicom.misc import is_dicom as is_dicom_file
    if os.path.isdir(filename):
        for root, _, filenames in os.walk(filename):
            for name in filenames:
                if is_dicom_file(os.path.join(root, name)):
                    return True
        return False
    return os.path.isfile(filename) and is_dicom_file(filename)

main

main()

Source code in src/biomero_converter/main.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def main():
    parser = argparse.ArgumentParser(description='Convert file to ome format')
    parser.add_argument('--inputfile', required=True, help='input file')
    parser.add_argument('--outputfolder', required=True, help='output folder')
    parser.add_argument('--altoutputfolder', help='alternative output folder')
    parser.add_argument('--outputformat', help='output format: omezarr04, omezarr05, omezarr06 or ometiff', default='omezarr04')
    parser.add_argument('--show_progress', action='store_true')
    parser.add_argument('--verbose', action='store_true')
    # Allow additional arguments for source-specific parameters (e.g., --plateid)
    parser.add_argument('--plateid', help='Incucyte plate ID (optional)')
    parser.add_argument('--image_uuid', help='Leica image UUID (optional, default: all images) '
                                               'or DICOM SeriesInstanceUID (optional, default: the first series)')
    parser.add_argument('--position', type=int, help='Leica position index, for a tile scan with gaps between its tiles')
    args = parser.parse_args()

    init_logging('db_to_zarr.log', verbose=args.verbose)

    # Build source-specific kwargs
    source_kwargs = {}
    if hasattr(args, 'plateid') and args.plateid:
        source_kwargs['plate_id'] = args.plateid
    if args.image_uuid:
        source_kwargs['image_uuid'] = args.image_uuid
    if args.position is not None:
        source_kwargs['position'] = args.position

    result = convert(
        args.inputfile,
        args.outputfolder,
        alt_output_folder=args.altoutputfolder,
        output_format=args.outputformat,
        show_progress=args.show_progress,
        verbose=args.verbose,
        **source_kwargs
    )

    if result and result != '{}':
        print(result)
        sys.exit(0)
    else:
        print('Error')
        sys.exit(1)

ome_tiff_util

INVALID_XML_CHARS = re.compile('[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]') module-attribute

camel_to_snake(name)

Source code in src/biomero_converter/util.py
303
304
305
def camel_to_snake(name):
    name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
    return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()

camel_to_snake_keys_dict(dct)

Source code in src/biomero_converter/util.py
295
296
297
298
299
300
def camel_to_snake_keys_dict(dct):
    if isinstance(dct, dict):
        result = {camel_to_snake(key): camel_to_snake_keys_dict(value) for key, value in dct.items()}
    else:
        result = dct
    return result

convert_dotnet_ticks_to_datetime(net_ticks)

Source code in src/biomero_converter/util.py
175
176
def convert_dotnet_ticks_to_datetime(net_ticks):
    return datetime(1, 1, 1) + timedelta(microseconds=net_ticks // 10)

convert_to_s(value, unit)

Source code in src/biomero_converter/util.py
319
320
321
322
323
324
def convert_to_s(value, unit):
    conversions = {
        'ns': 1e-9, 'µs': 1e-6, 'us': 1e-6, 'ms': 1e-3,
        's': 1, 'min': 60, 'h': 3600, 'd': 86400
    }
    return value * conversions.get(unit, 1)

convert_to_um(value, unit)

Source code in src/biomero_converter/util.py
308
309
310
311
312
313
314
315
316
def convert_to_um(value, unit):
    conversions = {
        'nm': 1e-3, 'nanometer': 1e-3,
        'µm': 1, 'um': 1, 'micrometer': 1, 'micron': 1,
        'mm': 1e3, 'millimeter': 1e3,
        'cm': 1e4, 'centimeter': 1e4,
        'm': 1e6, 'meter': 1e6
    }
    return value * conversions.get(unit, 1)

create_binaryonly_metadata(metadata_filename, companion_uuid)

Source code in src/biomero_converter/ome_tiff_util.py
410
411
412
413
414
415
def create_binaryonly_metadata(metadata_filename, companion_uuid):
    ome = OME()
    ome.uuid = create_uuid()
    ome.creator = f'nl.biomero.OmeTiffWriter {VERSION}'
    ome.binary_only = OME.BinaryOnly(metadata_file=metadata_filename, uuid=companion_uuid)
    return to_xml(ome), ome.uuid

create_image_metadata(source, image_name, dim_order='tczyx', image_uuid=None, image_filename=None, instrument_id=None, objective_id=None, metadata_only=False, position=None)

Source code in src/biomero_converter/ome_tiff_util.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def create_image_metadata(source, image_name, dim_order='tczyx', image_uuid=None, image_filename=None,
                          instrument_id=None, objective_id=None, metadata_only=False, position=None):
    t, c, z, y, x = [source.get_shape()[source.dim_order.index(dim)] if dim in source.get_dim_order() else 1
                     for dim in 'tczyx']
    pixel_size = source.get_pixel_size_um()
    channels = source.get_channels()
    if source.is_rgb():
        ome_channels = [Channel(name='rgb', samples_per_pixel=3)]
    elif len(channels) < c:
        ome_channels = [Channel(name=f'{channeli}', samples_per_pixel=1) for channeli in range(c)]
    else:
        ome_channels = []
        for channeli, channel in enumerate(channels):
            ome_channel = Channel()
            ome_channel.name = channel.get('label', channel.get('Name', f'{channeli}'))
            ome_channel.samples_per_pixel = 1

            color = channel.get('color', channel.get('Color'))
            if color is not None:
                ome_channel.color = Color(rgba_to_int(color))
            acquisition_mode = channel.get('acquisition_mode', channel.get('AcquisitionMode'))
            if acquisition_mode:
                ome_channel.acquisition_mode = acquisition_mode
            emission_wavelength = channel.get('emission_wavelength', channel.get('EmissionWavelength'))
            if emission_wavelength is not None:
                ome_channel.emission_wavelength = emission_wavelength
                ome_channel.emission_wavelength_unit = channel.get('emission_wavelength_unit', UnitsLength.NANOMETER)
            excitation_wavelength = channel.get('excitation_wavelength', channel.get('ExcitationWavelength'))
            if excitation_wavelength is not None:
                ome_channel.excitation_wavelength = excitation_wavelength
                ome_channel.excitation_wavelength_unit = channel.get('excitation_wavelength_unit', UnitsLength.NANOMETER)
            pinhole_size = channel.get('pinhole_size', channel.get('PinholeSize'))
            if pinhole_size is not None:
                ome_channel.pinhole_size = pinhole_size
                pinhole_size_unit = channel.get('pinhole_size_unit', channel.get('PinholeSizeUnit'))
                if pinhole_size_unit:
                    ome_channel.pinhole_size_unit = pinhole_size_unit

            ome_channels.append(ome_channel)

    pixel_type = str(source.get_dtype())
    if pixel_type.startswith('float'):
        pixel_type = PixelType.DOUBLE if '64' in pixel_type else PixelType.FLOAT
    pixels = Pixels(
        dimension_order=Pixels_DimensionOrder(dim_order[::-1].upper()),
        type=PixelType(pixel_type),
        channels=ome_channels,
        size_t=t, size_c=c, size_z=z, size_y=y, size_x=x,
    )
    if metadata_only:
        pixels.metadata_only = MetadataOnly()
    elif image_uuid:
        tiff_data = TiffData()
        tiff_data.uuid = TiffData.UUID(value=image_uuid, file_name=image_filename)
        pixels.tiff_data_blocks=[tiff_data]

    if 'x' in pixel_size:
        pixels.physical_size_x = pixel_size['x']
        pixels.physical_size_x_unit = UnitsLength.MICROMETER
    if 'y' in pixel_size:
        pixels.physical_size_y = pixel_size['y']
        pixels.physical_size_y_unit = UnitsLength.MICROMETER
    if 'z' in pixel_size:
        pixels.physical_size_z = pixel_size['z']
        pixels.physical_size_z_unit = UnitsLength.MICROMETER
    significant_bits = source.get_significant_bits()
    if significant_bits:
        pixels.significant_bits = significant_bits
    if position:
        pixels.planes = create_planes(position, pixel_size, t, 1 if source.is_rgb() else c, z)

    image = Image(name=image_name, pixels=pixels)
    acquisition_datetime = source.get_acquisition_datetime()
    if acquisition_datetime:
        image.acquisition_date = acquisition_datetime
    index = pixels.id.split(':')[1]
    for channeli, channel in enumerate(pixels.channels):
        channel.id = f'Channel:{index}:{channeli}'
    if instrument_id is not None:
        image.instrument_ref = InstrumentRef(id=instrument_id)
    if objective_id is not None:
        objective_settings = ObjectiveSettings(id=objective_id)
        info = source.get_acquisition_metadata()
        refractive_index = info.get('refractive_index',
                                    source.get_model_metadata().get('ImmersionLiquid', {}).get('RefractiveIndex'))
        if refractive_index is not None:
            objective_settings.refractive_index = refractive_index
        image.objective_settings = objective_settings
    return image

create_metadata(source, dim_order='tczyx', uuid=None, image_uuids=None, image_filenames=None, wells=None, metadata_only=False)

Source code in src/biomero_converter/ome_tiff_util.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def create_metadata(source, dim_order='tczyx', uuid=None, image_uuids=None, image_filenames=None, wells=None,
                    metadata_only=False):
    ome = OME()
    if uuid is None:
        uuid = create_uuid()
    ome.uuid = uuid
    ome.creator = f'nl.biomero.OmeTiffWriter {VERSION}'

    acquisition_metadata = source.get_acquisition_metadata().copy()
    model_metadata = source.get_model_metadata()
    model_instrument = model_metadata.get('Instrument', {})
    model_objective = model_metadata.get('Objective', {})
    instrument_id = None
    objective_id = None
    if acquisition_metadata:
        microscope = Microscope()
        has_microscope = False
        manufacturer = acquisition_metadata.pop('manufacturer', model_instrument.get('Manufacturer'))
        if manufacturer is not None:
            microscope.manufacturer = manufacturer
            has_microscope = True
        model = acquisition_metadata.pop('model', model_instrument.get('Model', model_instrument.get('Name')))
        if model is not None:
            microscope.model = model
            has_microscope = True
        serial_number = acquisition_metadata.pop('serial_number', None)
        if serial_number is not None:
            microscope.serial_number = serial_number
            has_microscope = True

        objective = Objective()
        has_objective = False
        magnification = acquisition_metadata.pop('magnification',
                                            acquisition_metadata.pop('nominal_magnification',
                                                                acquisition_metadata.pop('NominalMagnification',
                                                                                         model_objective.get('Magnification'))))
        if magnification is not None:
            objective.nominal_magnification = magnification
            has_objective = True
        lens_na = acquisition_metadata.pop('n_a', acquisition_metadata.pop('lens_na', model_objective.get('LensNA')))
        if lens_na is not None:
            objective.lens_na = lens_na
            has_objective = True
        working_distance = acquisition_metadata.pop('working_distance', None)
        if working_distance is not None:
            objective.working_distance = working_distance
            objective.working_distance_unit = acquisition_metadata.pop('working_distance_unit', UnitsLength.MICROMETER)
            has_objective = True
        immersion = acquisition_metadata.pop('immersion', model_objective.get('ImmersionType'))
        # the model's immersions include LiMi's, such as Mineral Oil, that OME's enumeration does not
        if immersion in {member.value for member in Objective_Immersion}:
            ome_immersion = Objective_Immersion(immersion)
            objective.immersion = ome_immersion

        if has_microscope or has_objective:
            instrument = Instrument()
            instrument_id = instrument.id
            if has_microscope:
                instrument.microscope = microscope
            if has_objective:
                objective_id = objective.id
                instrument.objectives.append(objective)
            ome.instruments = [instrument]

    if source.is_screen():
        if wells is None:
            wells = source.get_wells()

        nrows, row_type = get_row_col_len_type(source.get_rows())
        ncols, col_type = get_row_col_len_type(source.get_columns())

        plate = Plate()
        plate.name = source.get_name()
        plate.rows = nrows
        plate.columns = ncols
        plate.row_naming_convention = row_type
        plate.column_naming_convention = col_type

        image_index = 0
        for well_id in wells:
            row, col = split_well_name(well_id)
            row_index = get_row_col_index(row)
            col_index = get_row_col_index(col)
            well = Well(row=row_index, column=col_index)
            well.id = f'Well:{row_index}:{col_index}'
            for field in source.get_fields():
                sample = WellSample(index=image_index)
                sample.id = f'WellSample:{row_index}:{col_index}:{field}'
                position = source.get_position_um(well_id)
                if 'x' in position:
                    sample.position_x = position['x']
                    sample.position_x_unit = UnitsLength.MICROMETER
                if 'y' in position:
                    sample.position_y = position['y']
                    sample.position_y_unit = UnitsLength.MICROMETER

                image_name = f'Well {well_id}, Field #{int(field) + 1}'
                image_uuid = image_uuids[image_index] if image_uuids is not None else None
                image_filename = image_filenames[image_index] if image_filenames is not None else None
                image = create_image_metadata(source,
                                              image_name,
                                              dim_order,
                                              image_uuid,
                                              image_filename,
                                              instrument_id=instrument_id,
                                              objective_id=objective_id,
                                              metadata_only=metadata_only)
                ome.images.append(image)

                image_ref = ImageRef(id=image.id)   # assign id at instantiation to avoid auto sequence increment
                sample.image_ref = image_ref
                well.well_samples.append(sample)

                image_index += 1

            plate.wells.append(well)

        ome.plates = [plate]
    else:
        image_filename0 = image_filenames[0] if image_filenames is not None else None
        ome.images = [
            create_image_metadata(source, source.get_name(), dim_order, ome.uuid, image_filename0,
                                  instrument_id=instrument_id, objective_id=objective_id,
                                  metadata_only=metadata_only, position=source.get_position_um())
        ]

    map_dict = {}
    for acq_key, acq_value in acquisition_metadata.items():
        if acq_key == 'XMLAnnotation':
            # cls = getattr(sys.modules[__name__], 'XMLAnnotation')
            # annotation = cls(acquisition_element)
            # TODO: fix this; does value need converting to XML?
            annotation = XMLAnnotation(value=acq_value)
            ome.structured_annotations.append(annotation)
        elif 'map' in acq_key.lower():
            map_dict.update(acq_value)
        elif isinstance(acq_value, (dict, list, tuple)):
            map_dict.update(flatten_dict({acq_key: acq_value}))
        else:
            map_dict[acq_key] = acq_value

    if map_dict:
        annotation = MapAnnotation(value=Map(ms=[Map.M(k=remove_invalid_xml_chars(key), value=remove_invalid_xml_chars(value))
                                                 for key, value in map_dict.items()]))
        ome.structured_annotations.append(annotation)

    return to_xml(ome)

create_planes(position_um, pixel_size_um, nt, nc, nz)

Source code in src/biomero_converter/ome_tiff_util.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
def create_planes(position_um, pixel_size_um, nt, nc, nz):
    # a plane per t, c, z at the image position, z planes stepping by the z pixel size
    planes = []
    for t in range(nt):
        for c in range(nc):
            for z in range(nz):
                plane = Plane(the_t=t, the_c=c, the_z=z)
                if 'x' in position_um:
                    plane.position_x = position_um['x']
                    plane.position_x_unit = UnitsLength.MICROMETER
                if 'y' in position_um:
                    plane.position_y = position_um['y']
                    plane.position_y_unit = UnitsLength.MICROMETER
                if 'z' in position_um:
                    plane.position_z = position_um['z'] + z * pixel_size_um.get('z', 0)
                    plane.position_z_unit = UnitsLength.MICROMETER
                planes.append(plane)
    return planes

create_resolution_metadata(source)

Source code in src/biomero_converter/ome_tiff_util.py
445
446
447
448
449
def create_resolution_metadata(source):
    pixel_size_um = source.get_pixel_size_um()
    resolution_unit = 'CENTIMETER'
    resolution = [1e4 / pixel_size_um[dim] for dim in 'xy']
    return resolution, resolution_unit

create_row_col_label(index, naming_convention)

Source code in src/biomero_converter/ome_tiff_util.py
437
438
439
440
441
442
def create_row_col_label(index, naming_convention):
    if naming_convention.lower() == NamingConvention.LETTER.name.lower():
        label = chr(ord('A') + index)
    else:
        label = index + 1
    return str(label)

create_uuid()

Source code in src/biomero_converter/ome_tiff_util.py
30
31
def create_uuid():
    return f'urn:uuid:{uuid.uuid4()}'

dicom_to_dict(obj)

Source code in src/biomero_converter/util.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def dicom_to_dict(obj):
    # Recursively convert a pydicom Dataset (or element value) to plain python types, using duck typing:
    # Dataset -> dict, Sequence / MultiValue -> list, DS / IS -> float / int, PersonName / UID -> str
    if hasattr(obj, 'iterall'):
        # Dataset: iterating yields its (top-level) data elements
        return {elem.keyword: dicom_to_dict(elem.value) for elem in obj
                if elem.keyword and elem.keyword != 'PixelData'}
    if obj is None or isinstance(obj, (bool, bytes)):
        return obj
    if isinstance(obj, int):
        return int(obj)
    if isinstance(obj, (float, Decimal)):
        return float(obj)
    if isinstance(obj, str):
        return str(obj)
    if isinstance(obj, Sequence):
        return [dicom_to_dict(item) for item in obj]
    return str(obj)

ensure_list(item)

Source code in src/biomero_converter/util.py
11
12
13
14
def ensure_list(item):
    if not isinstance(item, (list, tuple)):
        item = [item]
    return item

fix_bad_micro_value(value)

Source code in src/biomero_converter/util.py
365
366
367
368
def fix_bad_micro_value(value):
    if isinstance(value, str) and '\xa6\xcc' in value:
        value = value.replace('\xa6\xcc', '\xb5')
    return value

flatten_dict(dct, prefix='')

Source code in src/biomero_converter/util.py
281
282
283
284
285
286
287
288
289
290
291
292
def flatten_dict(dct, prefix=''):
    flat_dct = {}
    for key, value in dct.items():
        full_key = prefix + ':' + key if prefix else key
        if isinstance(value, dict):
            flat_dct.update(flatten_dict(value, full_key))
        elif isinstance(value, (list, tuple)):
            for index, item in enumerate(value):
                flat_dct.update(flatten_dict({str(index): item}, full_key))
        else:
            flat_dct[full_key] = value
    return flat_dct

get_bits_type(nbits)

Source code in src/biomero_converter/util.py
77
78
79
80
81
82
83
84
85
86
def get_bits_type(nbits):
    if nbits <= 8:
        dtype = np.uint8
    elif nbits <= 16:
        dtype = np.uint16
    elif nbits <= 32:
        dtype = np.uint32
    else:
        dtype = np.uint64
    return np.dtype(dtype)

get_filetitle(filename)

Source code in src/biomero_converter/util.py
93
94
def get_filetitle(filename):
    return os.path.basename(os.path.splitext(filename)[0])

get_level_from_scale(source_scales, target_scale=1)

Source code in src/biomero_converter/util.py
67
68
69
70
71
72
73
74
def get_level_from_scale(source_scales, target_scale=1):
    best_level_scale = 0, target_scale
    for level, scale in enumerate(source_scales):
        if np.isclose(scale, target_scale, rtol=1e-4):
            return level, 1
        if scale <= target_scale:
            best_level_scale = level, target_scale / scale
    return best_level_scale

get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size)

Source code in src/biomero_converter/util.py
43
44
45
46
47
48
49
50
51
52
53
54
55
def get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size):
    x_index = dim_order.index('x')
    y_index = dim_order.index('y')
    slices = [slice(None)] * len(dim_order)
    if 't' in dim_order:
        slices[dim_order.index('t')] = t
    if 'c' in dim_order and c is not None:  # None: all channels
        slices[dim_order.index('c')] = c
    if 'z' in dim_order:
        slices[dim_order.index('z')] = z
    slices[y_index] = slice(y, y + y_size)
    slices[x_index] = slice(x, x + x_size)
    return data[tuple(slices)]

get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1)

Source code in src/biomero_converter/util.py
58
59
60
61
62
63
64
def get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1):
    # limit number of pyramid levels, keeping the smallest level at least min_level_size pixels
    min_size = min(shape[dim_order.index('x')], shape[dim_order.index('y')])
    levels = 0
    while levels < max_levels and min_size / downscale ** (levels + 1) >= min_level_size:
        levels += 1
    return levels

get_row_col_index(label)

Source code in src/biomero_converter/ome_tiff_util.py
429
430
431
432
433
434
def get_row_col_index(label):
    if label.isdigit():
        index = int(label) - 1
    else:
        index = ord(label.upper()) - ord('A')
    return index

get_row_col_len_type(labels)

Source code in src/biomero_converter/ome_tiff_util.py
418
419
420
421
422
423
424
425
426
def get_row_col_len_type(labels):
    max_index = max(get_row_col_index(label) for label in labels)
    nlen = max_index + 1
    is_digits = [label.isdigit() for label in labels]
    if np.all(is_digits):
        naming_convention = NamingConvention.NUMBER
    else:
        naming_convention = NamingConvention.LETTER
    return nlen, naming_convention

get_rows_cols_plate(nwells)

Source code in src/biomero_converter/util.py
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_rows_cols_plate(nwells):
    nrows_cols = {
        6: (2, 3),
        12: (3, 4),
        24: (4, 6),
        48: (6, 8),
        96: (8, 12),
        384: (16, 24)
    }
    nrows, ncols = nrows_cols[nwells]
    rows = [chr(ord('A') + i) for i in range(nrows)]
    cols = [str(i + 1) for i in range(ncols)]
    return rows, cols

metadata_to_dict(xml_metadata)

Source code in src/biomero_converter/ome_tiff_util.py
18
19
20
21
22
def metadata_to_dict(xml_metadata):
    metadata = xml2dict(xml_metadata)
    if 'OME' in metadata:
        metadata = metadata['OME']
    return metadata

pad_leading_zero(input_string, num_digits=2)

Source code in src/biomero_converter/util.py
129
130
131
132
133
134
135
136
137
138
139
def pad_leading_zero(input_string, num_digits=2):
    output = str(input_string)
    is_well = not output[0].isdigit()
    if is_well:
        row, col = split_well_name(output, remove_leading_zeros=True)
        output = str(col)
    while len(output) < num_digits:
        output = '0' + output
    if is_well:
        output = row + output
    return output

parse_datetime(value)

A datetime from an ISO 8601 or other common date text, or None if it is none.

Source code in src/biomero_converter/util.py
162
163
164
165
166
167
168
169
170
171
172
def parse_datetime(value):
    """A datetime from an ISO 8601 or other common date text, or None if it is none."""
    if not value or isinstance(value, datetime):
        return value or None
    try:
        return datetime.fromisoformat(str(value))
    except ValueError:
        try:
            return dateutil.parser.parse(str(value))
        except (ValueError, OverflowError):
            return None

print_dict(value, tab=0, max_len=250, bullet=False)

Source code in src/biomero_converter/util.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def print_dict(value, tab=0, max_len=250, bullet=False):
    s = ''
    if isinstance(value, dict):
        for key, subvalue in value.items():
            s += '\n'
            if bullet:
                s += '-'
                bullet = False
            s += '\t' * tab + str(key) + ': '
            if isinstance(subvalue, dict):
                s += print_dict(subvalue, tab+1)
            elif isinstance(subvalue, list):
                for v in subvalue:
                    s += print_dict(v, tab+1, bullet=True)
            else:
                subvalue = str(subvalue)
                if len(subvalue) > max_len:
                    subvalue = subvalue[:max_len] + '...'
                s += subvalue
    else:
        s += str(value) + ' '
    return s

print_hbytes(nbytes)

Source code in src/biomero_converter/util.py
351
352
353
354
355
356
357
358
359
360
361
362
def print_hbytes(nbytes):
    exps = ['', 'K', 'M', 'G', 'T', 'P', 'E']
    div = 1024
    exp = 0
    while nbytes > div:
        nbytes /= div
        exp += 1
    if exp < len(exps):
        e = exps[exp]
    else:
        e = f'e{exp * 3}'
    return f'{nbytes:.1f}{e}B'

read_ome_acquisition_end_datetime(metadata)

Source code in src/biomero_converter/ome_tiff_util.py
136
137
138
139
140
141
142
143
144
145
146
147
def read_ome_acquisition_end_datetime(metadata):
    # the acquisition date plus the time of its last plane
    image0 = ensure_list(metadata.get('Image', []))[0]
    acquisition_datetime = image0.get('AcquisitionDate')
    planes = ensure_list(image0.get('Pixels', {}).get('Plane', []))
    delta_ts = [convert_to_s(float(plane['DeltaT']), plane.get('DeltaTUnit', 's'))
                for plane in planes if plane.get('DeltaT') is not None]
    if not acquisition_datetime or not delta_ts:
        return None
    if isinstance(acquisition_datetime, str):
        acquisition_datetime = datetime.fromisoformat(acquisition_datetime)
    return acquisition_datetime + timedelta(seconds=max(delta_ts))

read_ome_xml_metadata(metadata)

Source code in src/biomero_converter/ome_tiff_util.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def read_ome_xml_metadata(metadata):
    pixel_size = {}
    position = {}
    channels = []
    rows = set()
    columns = set()
    fields = set()
    wells = {}
    image_refs = {}
    acquisition_metadata = {}

    image0 = ensure_list(metadata.get('Image', []))[0]
    is_plate = 'Plate' in metadata
    if is_plate:
        for plate in ensure_list(metadata['Plate']):
            name = plate.get('Name')
            row_naming_convention = plate.get('RowNamingConvention', NamingConvention.LETTER.name)
            column_naming_convention = plate.get('ColumnNamingConvention', NamingConvention.NUMBER.name)
            for well in ensure_list(plate.get('Well', [])):
                row = create_row_col_label(well['Row'], row_naming_convention)
                column = create_row_col_label(well['Column'], column_naming_convention)
                rows.add(row)
                columns.add(column)
                label = f'{row}{column}'
                wells[label] = well['ID']
                image_refs[label] = {}
                for sample in ensure_list(well.get('WellSample', [])):
                    sample_id_parts = sample['ID'].split(':')
                    field_id = sample_id_parts[-1]
                    fields.add(int(field_id))
                    image_refs[label][int(field_id)] = sample['ImageRef']['ID']
            if 'Rows' in plate:
                rows = [create_row_col_label(row, row_naming_convention) for row in range(plate['Rows'])]
            else:
                rows = sorted(rows)
            if 'Columns' in plate:
                columns = [create_row_col_label(col, column_naming_convention) for col in range(plate['Columns'])]
            else:
                columns = sorted(columns, key=int)
        wells = list(wells.keys())
        fields = sorted(fields)
    else:
        name = image0.get('Name')
    acquisition_datetime = image0.get('AcquisitionDate')
    pixels = image0.get('Pixels', {})
    dtype0 = pixels['Type'].lower()
    if dtype0 in ['float', 'double']:
        dtype0 = 'float64' if dtype0 == 'double' else 'float32'
    dtype = np.dtype(dtype0)
    if 'PhysicalSizeX' in pixels:
        pixel_size['x'] = convert_to_um(float(pixels.get('PhysicalSizeX')), pixels.get('PhysicalSizeXUnit'))
    if 'PhysicalSizeY' in pixels:
        pixel_size['y'] = convert_to_um(float(pixels.get('PhysicalSizeY')), pixels.get('PhysicalSizeYUnit'))
    if 'PhysicalSizeZ' in pixels:
        pixel_size['z'] = convert_to_um(float(pixels.get('PhysicalSizeZ')), pixels.get('PhysicalSizeZUnit'))
    plane = ensure_list(pixels.get('Plane', []))[0] if pixels.get('Plane') else None
    if plane:
        if 'PositionX' in plane:
            position['x'] = convert_to_um(float(plane.get('PositionX')), plane.get('PositionXUnit'))
        if 'PositionY' in plane:
            position['y'] = convert_to_um(float(plane.get('PositionY')), plane.get('PositionYUnit'))
        if 'PositionZ' in plane:
            position['z'] = convert_to_um(float(plane.get('PositionZ')), plane.get('PositionZUnit'))
    for channel0 in ensure_list(pixels.get('Channel', [])):
        channel = {}
        if 'Name' in channel0:
            channel['label'] = channel0['Name']
        if 'Color' in channel0:
            channel['color'] = int_to_rgba(channel0['Color'])
        # all additional channel metadata
        for key, value in channel0.items():
            if key not in ['Name', 'Color'] and value is not None:
                channel[camel_to_snake(key)] = value
        channels.append(channel)
    if 'SignificantBits' in pixels:
        bits_per_pixel = int(pixels['SignificantBits'])
    else:
        bits_per_pixel = dtype.itemsize * 8

    # all additional metadata
    acquisition_metadata.update(camel_to_snake_keys_dict(metadata.get('Instrument', {})))
    acquisition_metadata.update(camel_to_snake_keys_dict(image0.get('ObjectiveSettings', {})))

    # empty element gives None
    for annotations_type, annotations in (metadata.get('StructuredAnnotations') or {}).items():
        for annotation in ensure_list(annotations):
            key, value = annotation.get('ID'), annotation.get('Value')
            if 'Namespace' in annotation:
                key = annotation['Namespace']
            if 'pyramidresolution' not in key.lower():
                if isinstance(value, dict) and 'M' in value:
                    value = {item.get('K'): item.get('value') for item in ensure_list(value['M'])}
                acquisition_metadata[key] = value

    return (name, is_plate, pixel_size, position, dtype, bits_per_pixel, channels, acquisition_metadata, acquisition_datetime,
            wells, list(rows), list(columns), list(fields), image_refs)

redimension_data(data, old_order, new_order, **indices)

Source code in src/biomero_converter/util.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def redimension_data(data, old_order, new_order, **indices):
    # able to provide optional dimension values e.g. t=0, z=0
    if new_order == old_order:
        return data

    new_data = data
    order = old_order
    # remove
    for o in old_order:
        if o not in new_order:
            index = order.index(o)
            dim_value = indices.get(o, 0)
            new_data = np.take(new_data, indices=dim_value, axis=index)
            order = order[:index] + order[index + 1:]
    # add
    for o in new_order:
        if o not in order:
            new_data = np.expand_dims(new_data, 0)
            order = o + order
    # move
    old_indices = [order.index(o) for o in new_order]
    new_indices = list(range(len(new_order)))
    new_data = np.moveaxis(new_data, old_indices, new_indices)
    return new_data

remove_invalid_xml_chars(value)

Source code in src/biomero_converter/ome_tiff_util.py
25
26
27
def remove_invalid_xml_chars(value):
    # control characters (e.g. a \x01 user name in Ciqtek metadata) are not allowed in XML 1.0
    return INVALID_XML_CHARS.sub('', str(value))

remove_key_prefix(obj, prefix)

Source code in src/biomero_converter/util.py
271
272
273
274
275
276
277
278
def remove_key_prefix(obj, prefix):
    # recursively remove prefix from dict keys
    if isinstance(obj, dict):
        return {(key[len(prefix):] if key.startswith(prefix) and len(key) > len(prefix) else key):
                remove_key_prefix(value, prefix) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [remove_key_prefix(item, prefix) for item in obj]
    return obj

reset_ome_ids()

Source code in src/biomero_converter/ome_tiff_util.py
34
35
def reset_ome_ids():
    ID_COUNTER.clear()   # this will reset all reference/ids

split_well_name(well_name, remove_leading_zeros=True, col_as_int=False)

Source code in src/biomero_converter/util.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def split_well_name(well_name, remove_leading_zeros=True, col_as_int=False):
    matches = re.findall(r'(\D+)(\d+)', well_name)
    if len(matches) > 0:
        row, col = matches[0]
        if col_as_int or remove_leading_zeros:
            try:
                col = int(col)
            except ValueError:
                pass
        if not col_as_int:
            col = str(col)
        return row, col
    else:
        raise ValueError(f"Invalid well name format: {well_name}. Expected format like 'A1', 'B2', etc.")

splitall(path)

Source code in src/biomero_converter/util.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def splitall(path):
    allparts = []
    while True:
        parts = os.path.split(path)
        if parts[0] == path:  # sentinel for absolute paths
            allparts.insert(0, parts[0])
            break
        elif parts[1] == path: # sentinel for relative paths
            allparts.insert(0, parts[1])
            break
        else:
            path = parts[0]
            allparts.insert(0, parts[1])
    return allparts

strip_leading_zeros(well_name)

Source code in src/biomero_converter/util.py
142
143
144
def strip_leading_zeros(well_name):
    row, col = split_well_name(well_name, remove_leading_zeros=True)
    return f'{row}{col}'

to_plain_types(obj)

Source code in src/biomero_converter/util.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def to_plain_types(obj):
    # Recursively convert metadata to plain python types (e.g. for yaml): tuple -> list, numpy -> python,
    # bytes -> str, Enum -> name; datetime is kept, anything else unknown becomes str
    if isinstance(obj, dict):
        return {str(key): to_plain_types(value) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [to_plain_types(item) for item in obj]
    if isinstance(obj, np.ndarray):
        return to_plain_types(obj.tolist())
    if isinstance(obj, np.generic):
        return obj.item()
    if isinstance(obj, bytes):
        return obj.decode('utf-8', errors='replace')
    if isinstance(obj, Enum):
        return obj.name
    if obj is None or isinstance(obj, (bool, int, float, str, datetime)):
        return obj
    return str(obj)

validate_filename(filename)

Source code in src/biomero_converter/util.py
89
90
def validate_filename(filename):
    return re.sub(r'[^\w_.)(-]', '_', filename)

without_keys(value, keys)

Source code in src/biomero_converter/util.py
262
263
264
265
266
267
268
def without_keys(value, keys):
    # recursively drop the entries named by keys
    if isinstance(value, dict):
        return {key: without_keys(item, keys) for key, item in value.items() if key not in keys}
    if isinstance(value, (list, tuple)):
        return [without_keys(item, keys) for item in value]
    return value

xml_content_to_dict(element)

Source code in src/biomero_converter/util.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def xml_content_to_dict(element):
    key = element.tag
    children = list(element)
    if key == 'Array':
        res = [xml_content_to_dict(child) for child in children]
        return res
    if len(children) > 0:
        if children[0].tag == 'Array':
            value = []
        else:
            value = {}
        for child in children:
            child_value = xml_content_to_dict(child)
            if isinstance(child_value, list):
                value.extend(child_value)
            else:
                value |= child_value
    else:
        value = element.text
        if value is not None:
            if '"' in value:
                value = value.replace('"', '')
            else:
                for t in (float, int, bool):
                    try:
                        if t == bool:
                            if value.lower() == 'true':
                                value = True
                            if value.lower() == 'false':
                                value = False
                        else:
                            value = t(value)
                        break
                    except (TypeError, ValueError):
                        pass

    if key == 'DataObject':
        key = element.attrib['ObjectType']
    if key == 'Attribute':
        key = element.attrib['Name']
    return {key: value}

ome_zarr_util

convert_multiscales_from_v06(multiscales)

Convert OME-Zarr 0.6 (RFC-5) multiscales metadata to the 0.4/0.5 layout (axes and a list of scale / translation transformations per dataset), so it can be read like earlier versions.

Parameters:

Name Type Description Default
multiscales list

Multiscales metadata.

required

Returns:

Name Type Description
list

Multiscales metadata in the 0.5 layout.

Source code in src/biomero_converter/ome_zarr_util.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def convert_multiscales_from_v06(multiscales):
    """
    Convert OME-Zarr 0.6 (RFC-5) multiscales metadata to the 0.4/0.5 layout (axes and a list of
    scale / translation transformations per dataset), so it can be read like earlier versions.

    Args:
        multiscales (list): Multiscales metadata.

    Returns:
        list: Multiscales metadata in the 0.5 layout.
    """
    converted = []
    for multiscale in multiscales:
        multiscale = dict(multiscale)
        coordinate_systems = multiscale.pop('coordinateSystems', None)
        if coordinate_systems is None:
            converted.append(multiscale)
            continue
        datasets = []
        for dataset in multiscale['datasets']:
            output = None
            transforms = []
            for transform in dataset['coordinateTransformations']:
                output = transform.get('output', {}).get('name', output)
                if transform['type'] == 'sequence':
                    transforms += transform['transformations']
                else:
                    transforms.append(transform)
            transforms = [{key: value for key, value in transform.items() if key not in ('input', 'output')}
                          for transform in transforms]
            datasets.append({**dataset, 'coordinateTransformations': transforms})
        # use the axes of the coordinate system the datasets map to
        names = [coordinate_system['name'] for coordinate_system in coordinate_systems]
        index = names.index(output) if output in names else 0
        multiscale['axes'] = coordinate_systems[index]['axes']
        multiscale['datasets'] = datasets
        converted.append(multiscale)
    return converted

convert_multiscales_to_v06(multiscales, coordinate_system='physical')

Convert OME-Zarr 0.4/0.5 multiscales metadata to the 0.6 (RFC-5) layout.

The axes move into a named coordinate system, and the transformations of each dataset become a single transformation (or a sequence of scale and translation) from the dataset path to that coordinate system.

Parameters:

Name Type Description Default
multiscales list

Multiscales metadata.

required
coordinate_system str

Name of the (physical) coordinate system.

'physical'

Returns:

Name Type Description
list

Multiscales metadata in the 0.6 layout.

Source code in src/biomero_converter/ome_zarr_util.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def convert_multiscales_to_v06(multiscales, coordinate_system='physical'):
    """
    Convert OME-Zarr 0.4/0.5 multiscales metadata to the 0.6 (RFC-5) layout.

    The axes move into a named coordinate system, and the transformations of each dataset become
    a single transformation (or a sequence of scale and translation) from the dataset path
    to that coordinate system.

    Args:
        multiscales (list): Multiscales metadata.
        coordinate_system (str): Name of the (physical) coordinate system.

    Returns:
        list: Multiscales metadata in the 0.6 layout.
    """
    converted = []
    for multiscale in multiscales:
        multiscale = dict(multiscale)
        multiscale['coordinateSystems'] = [{'name': coordinate_system, 'axes': multiscale.pop('axes')}]
        datasets = []
        for dataset in multiscale['datasets']:
            path = dataset['path']
            transforms = dataset['coordinateTransformations']
            if len(transforms) == 1:
                transform = dict(transforms[0])
            else:
                transform = {'type': 'sequence', 'transformations': transforms}
            transform['input'] = {'path': path}
            transform['output'] = {'name': coordinate_system}
            datasets.append({**dataset, 'coordinateTransformations': [transform]})
        multiscale['datasets'] = datasets
        converted.append(multiscale)
    return converted

create_axes_metadata(dimension_order)

Create axes metadata for OME-Zarr from dimension order.

Parameters:

Name Type Description Default
dimension_order str

String of dimension characters.

required

Returns:

Name Type Description
list

List of axis metadata dictionaries.

Source code in src/biomero_converter/ome_zarr_util.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def create_axes_metadata(dimension_order):
    """
    Create axes metadata for OME-Zarr from dimension order.

    Args:
        dimension_order (str): String of dimension characters.

    Returns:
        list: List of axis metadata dictionaries.
    """
    axes = []
    for dimension in dimension_order:
        unit1 = None
        if dimension == 't':
            type1 = 'time'
            unit1 = 'millisecond'
        elif dimension == 'c':
            type1 = 'channel'
        else:
            type1 = 'space'
            unit1 = 'micrometer'
        axis = {'name': dimension, 'type': type1}
        if unit1 is not None and unit1 != '':
            axis['unit'] = unit1
        axes.append(axis)
    return axes

create_channel_metadata(dtype, channels, nchannels, is_rgb, window, ome_version)

Create channel metadata for OME-Zarr.

Parameters:

Name Type Description Default
dtype

Numpy dtype of image data.

required
channels list

List of channel dicts.

required
nchannels int

Number of channels.

required
window tuple

Min/max window values.

required
ome_version str

OME-Zarr version.

required

Returns:

Name Type Description
dict

Channel metadata dictionary.

Source code in src/biomero_converter/ome_zarr_util.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def create_channel_metadata(dtype, channels, nchannels, is_rgb, window, ome_version):
    """
    Create channel metadata for OME-Zarr.

    Args:
        dtype: Numpy dtype of image data.
        channels (list): List of channel dicts.
        nchannels (int): Number of channels.
        window (tuple): Min/max window values.
        ome_version (str): OME-Zarr version.

    Returns:
        dict: Channel metadata dictionary.
    """
    if len(channels) < nchannels:
        labels = []
        colors = []
        if is_rgb and nchannels in (3, 4):
            labels = ['Red', 'Green', 'Blue']
            colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1)]
        if is_rgb and nchannels == 4:
            labels += ['Alpha']
            colors += [(1, 1, 1)]
        channels = [{'label': label, 'color': color} for label, color in zip(labels, colors)]

    omezarr_channels = []
    starts, ends = window
    for channeli, channel in enumerate(channels):
        omezarr_channel = {'label': channel.get('label', channel.get('Name', f'{channeli}')), 'active': True}
        color = channel.get('color', channel.get('Color'))
        # color is required in the omero metadata; default to white
        omezarr_channel['color'] = rgba_to_hexrgb(color) if color is not None else 'FFFFFF'
        if np.dtype(dtype).kind == 'f':
            min, max = 0, 1
        else:
            info = np.iinfo(dtype)
            min, max = info.min, info.max
        if len(starts) > 0 and len(ends) > 0:
            start, end = starts[channeli], ends[channeli]
        else:
            start, end = min, max
        omezarr_channel['window'] = {'min': min, 'max': max, 'start': start, 'end': end}
        omezarr_channels.append(omezarr_channel)

    metadata = {
        'version': ome_version,
        'channels': omezarr_channels,
    }
    return metadata

create_transformation_metadata(dimension_order, pixel_size_um, factor, translation_um=None)

Create transformation metadata (scale and translation) for OME-Zarr.

Parameters:

Name Type Description Default
dimension_order str

String of dimension characters.

required
pixel_size_um dict

Pixel size in micrometers per dimension.

required
factor float

Scaling factor.

required
translation_um dict

Translation in micrometers per dimension.

None

Returns:

Name Type Description
list

List of transformation metadata dictionaries.

Source code in src/biomero_converter/ome_zarr_util.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def create_transformation_metadata(dimension_order, pixel_size_um, factor, translation_um=None):
    """
    Create transformation metadata (scale and translation) for OME-Zarr.

    Args:
        dimension_order (str): String of dimension characters.
        pixel_size_um (dict): Pixel size in micrometers per dimension.
        factor (float): Scaling factor.
        translation_um (dict, optional): Translation in micrometers per dimension.

    Returns:
        list: List of transformation metadata dictionaries.
    """
    metadata = []
    pixel_size_scale = []
    translation_scale = []
    for dim in dimension_order:
        pixel_size_scale1 = pixel_size_um.get(dim, 1)
        if dim in 'xy':
            pixel_size_scale1 *= factor
        if pixel_size_scale1 == 0:
            pixel_size_scale1 = 1
        pixel_size_scale.append(pixel_size_scale1)

        if translation_um is not None:
            translation1 = translation_um.get(dim, 0)
            # translation_pyramid = translation + (scale - 1) * pixel_size / 2
            if dim in 'xy':
                translation1 += (factor - 1) * pixel_size_um[dim] / 2
            translation_scale.append(translation1)

    metadata.append({'type': 'scale', 'scale': pixel_size_scale})
    if translation_um is not None:
        metadata.append({'type': 'translation', 'translation': translation_scale})
    return metadata

patch_zarr_windows_network_move()

Work around zarr LocalStore failing on Windows network shares (see issue #10).

On Windows zarr uses os.rename for exclusive writes (set_if_not_exists) and relies on FileExistsError when the destination exists. Some network shares (SMB) report this as PermissionError [WinError 5] instead, which zarr does not handle.

Source code in src/biomero_converter/ome_zarr_util.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def patch_zarr_windows_network_move():
    """
    Work around zarr LocalStore failing on Windows network shares (see issue #10).

    On Windows zarr uses os.rename for exclusive writes (set_if_not_exists) and relies on
    FileExistsError when the destination exists. Some network shares (SMB) report this as
    PermissionError [WinError 5] instead, which zarr does not handle.
    """
    if sys.platform != 'win32':
        return
    import zarr.storage._local as zarr_local
    if getattr(zarr_local._safe_move, '_patched', False):
        return

    def _safe_move(src, dst):
        try:
            os.rename(src, dst)
        except PermissionError as e:
            if os.path.exists(dst):
                raise FileExistsError(*e.args) from e
            raise

    _safe_move._patched = True
    zarr_local._safe_move = _safe_move

scale_dimensions_dict(shape0, scale)

Scale x and y dimensions in a shape dictionary.

Parameters:

Name Type Description Default
shape0 dict

Original shape dictionary.

required
scale float

Scaling factor.

required

Returns:

Name Type Description
dict

Scaled shape dictionary.

Source code in src/biomero_converter/ome_zarr_util.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def scale_dimensions_dict(shape0, scale):
    """
    Scale x and y dimensions in a shape dictionary.

    Args:
        shape0 (dict): Original shape dictionary.
        scale (float): Scaling factor.

    Returns:
        dict: Scaled shape dictionary.
    """
    shape = {}
    if scale == 1:
        return shape0
    for dimension, shape1 in shape0.items():
        if dimension[0] in ['x', 'y']:
            shape1 = int(shape1 * scale)
        shape[dimension] = shape1
    return shape

scale_dimensions_xy(shape0, dimension_order, scale)

Scale x and y dimensions in a shape tuple.

Parameters:

Name Type Description Default
shape0 tuple

Original shape.

required
dimension_order str

String of dimension characters.

required
scale float

Scaling factor.

required

Returns:

Name Type Description
list

Scaled shape.

Source code in src/biomero_converter/ome_zarr_util.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def scale_dimensions_xy(shape0, dimension_order, scale):
    """
    Scale x and y dimensions in a shape tuple.

    Args:
        shape0 (tuple): Original shape.
        dimension_order (str): String of dimension characters.
        scale (float): Scaling factor.

    Returns:
        list: Scaled shape.
    """
    shape = []
    if scale == 1:
        return shape0
    for shape1, dimension in zip(shape0, dimension_order):
        if dimension[0] in ['x', 'y']:
            shape1 = int(shape1 * scale)
        shape.append(shape1)
    return shape

parameters

OME_DIR = 'OME' module-attribute

OME_FILE = 'METADATA.ome.xml' module-attribute

PYRAMID_DOWNSCALE = 2 module-attribute

PYRAMID_LEVELS = 6 module-attribute

RETRY_ATTEMPTS = 3 module-attribute

TIFF_COMPRESSION = 'LZW' module-attribute

TILE_SIZE = 1024 module-attribute

VERSION = get_version() module-attribute

ZARR_CHUNK_SIZE = TILE_SIZE module-attribute

ZARR_SHARD_MULTIPLIER = 10 module-attribute

rocrate_utils

ACQUISITION_METADATA_FILENAME = 'acquisition-metadata.yaml' module-attribute

CONVERTER_URL = 'https://github.com/NL-BioImaging/biomero-converter' module-attribute

MODEL_SCHEMA_URL = 'https://github.com/NL-BioImaging/imaging-metadata-converter/blob/{release}/src/imaging_metadata_converter/models/' + os.path.basename(DEFAULT_MODEL_FILE) module-attribute

add_instrument(crate, source, model_instrument)

The instrument named in the metadata, identified by what names it; None if the metadata does not.

Source code in src/biomero_converter/rocrate_utils.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def add_instrument(crate, source, model_instrument):
    """The instrument named in the metadata, identified by what names it; None if the metadata does not."""
    instrument_properties = {}

    metadata = source.get_metadata()
    # fallback: search the decoded acquisition metadata first, then the source metadata
    searchable = {'acquisition': source.get_acquisition_metadata(), 'source': metadata}

    if model_instrument.get('Manufacturer'):
        manufacturer = model_instrument['Manufacturer']
    elif 'manufacturer' in metadata and metadata['manufacturer']:
        manufacturer = metadata['manufacturer']
    else:
        manufacturer = search_metadata_fully(searchable, ['manufacturer', 'make'],
                                             contexts=['instrument', 'microscope', 'device', 'system', ''])
    if manufacturer:
        instrument_properties['manufacturer'] = manufacturer

    if model_instrument.get('Model') or model_instrument.get('Name'):
        model = model_instrument.get('Model') or model_instrument['Name']
    elif 'model' in metadata and metadata['model']:
        model = metadata['model']
    else:
        # a name, product or identifier only names the instrument within its context
        model = search_metadata_fully(searchable, ['model', 'name', 'product', 'productname', 'identifier'],
                                      contexts=['instrument', 'microscope', 'device', 'system'])
        if model is None:
            model = search_metadata_fully(searchable, ['model'], contexts=[''])
    if model:
        instrument_properties['name'] = model

    if 'serial' in metadata and metadata['serial']:
        serial = metadata['serial']
    else:
        serial = search_metadata_fully(searchable, ['serialnumber', 'serial'],
                                       contexts=['instrument', 'microscope', 'device', 'system', ''])
    if serial:
        # schema.org serialNumber is text
        instrument_properties['serialNumber'] = str(serial)

    if not instrument_properties:
        return None
    # the same instrument gets the same id in every crate
    label = re.sub(r'[^a-z0-9]+', '-', ' '.join(map(str, instrument_properties.values())).lower()).strip('-')
    return crate.add(ContextEntity(crate, identifier=f'#instrument-{label}',
                                   properties={'@type': 'IndividualProduct', **instrument_properties}))

create_ro_crate(source, dest_path={})

Source code in src/biomero_converter/rocrate_utils.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def create_ro_crate(source, dest_path={}):
    crate = ZarrCrate()
    # Alternative use github German-BioImaging idr_study_crates GraphBuilder class to low-level build instead?

    # converted datasets are kept apart from their sources: the source is known by its name, not a path
    source_name = os.path.basename(os.path.normpath(source.uri))
    # the crate is published as the conversion ends
    conversion_time = to_iso_datetime(datetime.now())

    properties = {}
    properties['name'] = source.get_name()  # use output path(s) instead
    properties['description'] = f'OME-Zarr image converted by biomero-converter from {source_name}'
    properties['datePublished'] = conversion_time
    properties['encodingFormat'] = [
        'application/vnd.zarr',
        {'@id': 'https://openminds.docs.om-i.org/en/v3.0/instance_libraries/contentTypes.html#application-vnd-zarr'}
    ]
    #properties["license"] = source.get_license()
    dataset_entity = crate.add_dataset(dest_path='.', properties=properties)

    # acquisition metadata on the common imaging metadata model, written to a yaml file the crate points to
    model_metadata = {key: value for key, value in source.get_model_metadata().items() if key != SOURCE_MAP_KEY}
    # given as the file's source, so the crate writes the file itself
    # libyaml's dumper where installed: a Leica tile scan's metadata is tens of thousands of values
    yaml_text = yaml.dump(to_plain_types(model_metadata), Dumper=getattr(yaml, 'CSafeDumper', yaml.SafeDumper),
                          sort_keys=False, allow_unicode=True)
    acquisition_metadata_entity = crate.add_file(
        source=StringIO(yaml_text), dest_path=ACQUISITION_METADATA_FILENAME, properties={
        'name': 'Acquisition metadata',
        'description': 'Image acquisition metadata converted to the imaging metadata model '
                       '(https://github.com/NL-BioImaging/imaging-metadata-converter)',
        'encodingFormat': 'application/yaml',
    })
    # the dataset entity replaced the crate's root dataset, so link the file to it explicitly
    dataset_entity.append_to('hasPart', acquisition_metadata_entity)

    source_entity = crate.add(ContextEntity(crate, identifier='#source-' + quote(source_name), properties={
        '@type': 'Dataset' if os.path.isdir(source.uri) else 'File',
        'name': source_name,
    }))
    dataset_entity['isBasedOn'] = source_entity

    # the microscope made the source, the converter the zarr from it
    create_entity = crate.add(ContextEntity(crate, identifier='#data-capture-001', properties={
        '@type': 'CreateAction',
        'name': 'Image acquisition',
    }))
    instrument_entity = add_instrument(crate, source, model_metadata.get('Instrument', {}))
    if instrument_entity:
        create_entity['instrument'] = instrument_entity
    create_entity['result'] = source_entity
    try:
        start_time = source.get_acquisition_datetime()
    except NotImplementedError:
        start_time = None
    if start_time:
        create_entity['startTime'] = to_iso_datetime(start_time)
    end_time = source.get_acquisition_end_datetime()
    if end_time:
        create_entity['endTime'] = to_iso_datetime(end_time)
    # list the actions on the root, so they can be found from there
    dataset_entity.append_to('mentions', create_entity)

    converter_entity = crate.add(ContextEntity(crate, identifier=CONVERTER_URL, properties={
        '@type': 'SoftwareApplication',
        'name': 'biomero-converter',
        'url': CONVERTER_URL,
        'version': get_version(),
    }))
    conversion_entity = crate.add(ContextEntity(crate, identifier='#conversion-001', properties={
        '@type': 'CreateAction',
        'name': 'Conversion to OME-Zarr',
        # which image of the source was converted (a Leica file holds several)
        'description': f'Image {source.get_name()} converted from {source_name}',
        'endTime': conversion_time,
    }))
    conversion_entity['instrument'] = converter_entity
    conversion_entity['object'] = source_entity
    conversion_entity['result'] = dataset_entity
    dataset_entity.append_to('mentions', conversion_entity)

    # the metadata file describes the acquisition, on the model's LinkML schema
    acquisition_metadata_entity['about'] = create_entity
    schema_properties = {
        '@type': ['CreativeWork', 'Profile'],
        'name': 'Imaging metadata model (LinkML schema)',
        'encodingFormat': 'application/yaml',
    }
    schema_version = get_model_schema_version()
    if schema_version:
        schema_properties['version'] = schema_version
    # the model's release is tagged with its version
    schema_url = MODEL_SCHEMA_URL.format(release=f'v{schema_version}' if schema_version else 'main')
    schema_entity = crate.add(ContextEntity(crate, identifier=schema_url, properties=schema_properties))
    acquisition_metadata_entity['conformsTo'] = schema_entity

    # TODO: Can add variableMeasured for output properties

    crate.write(dest_path)
    return crate

get_model_schema_version()

Source code in src/biomero_converter/rocrate_utils.py
182
183
184
185
186
187
188
189
def get_model_schema_version():
    # the version is in the schema's header: read up to it rather than loading the whole (large) schema
    with open(DEFAULT_MODEL_FILE, encoding='utf-8') as file:
        for line in file:
            match = re.match(r'version:\s*(\S+)', line)
            if match:
                return match.group(1).strip('\'"')
    return None

normalise_key(key)

Source code in src/biomero_converter/rocrate_utils.py
218
219
def normalise_key(key):
    return re.sub(r'[^a-z0-9]', '', str(key).lower())

search_metadata(metadata, label, context='', path=())

The first value whose key contains the label, below or at a key containing the context; without a context, whose key is the label (EXIF Make, Model).

Source code in src/biomero_converter/rocrate_utils.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def search_metadata(metadata, label, context='', path=()):
    """The first value whose key contains the label, below or at a key containing the context; without a context,
    whose key is the label (EXIF Make, Model)."""
    for key, value in metadata.items():
        key1 = normalise_key(key)
        if isinstance(value, dict):
            match = search_metadata(value, label, context, path + (key1,))
            if match is not None:
                return match
        elif context:
            if label in key1 and any(context in part for part in path + (key1,)):
                return value
        elif key1 == label:
            return value
    return None

search_metadata_fully(metadata, labels, contexts=None)

Source code in src/biomero_converter/rocrate_utils.py
192
193
194
195
196
197
198
def search_metadata_fully(metadata, labels, contexts=None):
    for context in contexts:
        for label in labels:
            value = search_metadata(metadata, label, context)
            if value is not None:
                return value
    return None

to_iso_datetime(value)

Source code in src/biomero_converter/rocrate_utils.py
176
177
178
179
def to_iso_datetime(value):
    if hasattr(value, 'isoformat'):
        return value.isoformat()
    return str(value)

tiff_metadata

Read the metadata of a TIFF file, and what it says about space.

Vendors write the pixel size and the stage position in their own tags, under their own names and in their own units, so rather than reading the tags of particular vendors, the whole metadata tree is searched for the names these values are known by, and every value is normalised to micrometres. That way a vendor which is not known here is picked up as well, as long as it names its fields the way the rest do.

AXIS_SUFFIXES = (('x', 'x'), ('y', 'y'), ('z', 'z'), ('width', 'x'), ('height', 'y'), ('depth', 'z')) module-attribute

DIMENSION_SUFFIXES = ('width', 'height', 'depth') module-attribute

EXIF_TAG_NAME = 'ExifTag' module-attribute

FIELD_OF_VIEW_NAMES = ('fov', 'fieldofview', 'fieldwidth', 'hfw', 'horizontalfieldwidth', 'vfw') module-attribute

IDENTITY_TAG_NAMES = ('Make', 'Model', 'Software', 'HostComputer') module-attribute

MOJIBAKE = {'¦Ì': 'µ'} module-attribute

PIXEL_SIZE_NAMES = ('pixelsize', 'pixelspacing', 'physicalsize') module-attribute

PLAUSIBLE_PIXEL_SIZE_UM = (1e-05, 10000.0) module-attribute

POSITION_CONTAINER_NAMES = ('stage', 'position') module-attribute

POSITION_NAMES = ('stage', 'stagepos', 'position', 'pos') module-attribute

PRIVATE_TAG_CODE = 32768 module-attribute

QUANTITY_PATTERN = re.compile('^\\s*([-+]?[\\d.]+(?:[eE][-+]?\\d+)?)\\s*([^\\d\\s]*)\\s*$') module-attribute

SCREEN_RESOLUTIONS = (72, 96) module-attribute

STAGE_TRAVEL_PATTERN = re.compile('(\\d{1,4})x(\\d{1,4})(?:x(\\d{1,4}))?', re.IGNORECASE) module-attribute

UM_CONVERSIONS = {'a': 0.0001, 'angstrom': 0.0001, 'nm': 0.001, 'nanometer': 0.001, 'nanometre': 0.001, 'um': 1, 'µm': 1, 'micrometer': 1, 'micrometre': 1, 'micron': 1, 'mm': 1000.0, 'millimeter': 1000.0, 'millimetre': 1000.0, 'cm': 10000.0, 'centimeter': 10000.0, 'centimetre': 10000.0, 'm': 1000000.0, 'meter': 1000000.0, 'metre': 1000000.0, 'inch': 25400, 'in': 25400} module-attribute

UNIT_KEYS = ('unit', 'units') module-attribute

VALUE_KEYS = ('value', 'val') module-attribute

XSI_NAMESPACE = '{http://www.w3.org/2001/XMLSchema-instance}' module-attribute

logger = logging.getLogger(__name__) module-attribute

convert_to_um(value, unit)

Return a value in micrometres, or None if it cannot be read.

Source code in src/biomero_converter/tiff_metadata.py
239
240
241
242
243
244
245
246
247
248
def convert_to_um(value: Any, unit: Any) -> Optional[float]:
    """Return a value in micrometres, or None if it cannot be read."""
    try:
        value = float(value)
    except (TypeError, ValueError):
        return None
    factor = UM_CONVERSIONS.get(normalise_name(unit))
    if factor is None:
        return None
    return value * factor

decode_metadata(value)

Decode a metadata value into the structure it holds.

Vendors store their metadata as an xml document or as a nested mapping, so parse the xml into a mapping, keeping its root element, such as OME, FeiImage or Fibics, which names the vendor's schema.

Source code in src/biomero_converter/tiff_metadata.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def decode_metadata(value: Any) -> Any:
    """Decode a metadata value into the structure it holds.

    Vendors store their metadata as an xml document or as a nested
    mapping, so parse the xml into a mapping, keeping its root element,
    such as OME, FeiImage or Fibics, which names the vendor's schema.
    """
    if isinstance(value, Enum):
        return value.name
    if isinstance(value, str):
        parsed = parse_xml(value)
        if parsed is None:
            return value
        value = parsed
    return drop_document_details(value)

drop_document_details(value)

Recursively drop the entries describing the xml document itself.

The plumbing appears at every level of a vendor's schema, not just at the top, so this has to walk the whole tree.

Source code in src/biomero_converter/tiff_metadata.py
114
115
116
117
118
119
120
121
122
123
124
125
126
def drop_document_details(value: Any) -> Any:
    """Recursively drop the entries describing the xml document itself.

    The plumbing appears at every level of a vendor's schema, not just
    at the top, so this has to walk the whole tree.
    """
    if isinstance(value, dict):
        return {key: drop_document_details(item)
                for key, item in value.items()
                if not is_document_detail(key, item)}
    if isinstance(value, list):
        return [drop_document_details(item) for item in value]
    return value

field_of_view_pixel_size(metadata, shape)

Return the pixel size from the width of the whole image.

Electron microscopes state the field of view rather than the pixel, so divide it by the pixels across the image.

Source code in src/biomero_converter/tiff_metadata.py
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def field_of_view_pixel_size(metadata: Dict,
                             shape: Dict[str, int]) -> Dict:
    """Return the pixel size from the width of the whole image.

    Electron microscopes state the field of view rather than the pixel,
    so divide it by the pixels across the image.
    """
    pixel_size = {}
    for key, value, siblings in walk_fields(metadata):
        name, axis, _ = split_axis(normalise_name(key))
        if not name.endswith(FIELD_OF_VIEW_NAMES):
            continue
        axis = axis or 'x'
        extent = parse_quantity(value, sibling_unit(key, siblings))
        pixels = shape.get(axis)
        if extent is None or not pixels:
            continue
        pixel_size.setdefault(axis, extent / pixels)
    # a field of view given for one axis describes square pixels
    if len(pixel_size) == 1:
        (size,) = pixel_size.values()
        pixel_size = {'x': size, 'y': size}
    return pixel_size

find_unit(keyed)

Return the unit a mapping names, if it names one.

Source code in src/biomero_converter/tiff_metadata.py
274
275
276
277
278
279
def find_unit(keyed: Dict[str, Any]) -> Optional[str]:
    """Return the unit a mapping names, if it names one."""
    for unit_key in UNIT_KEYS:
        if isinstance(keyed.get(unit_key), str):
            return keyed[unit_key]
    return None

get_extra_metadata(tif)

Return the vendor metadata in a TIFF file.

Rather than reading the tags of particular vendors, every private tag is collected and normalised the same way, so that vendors which are not known here are picked up as well. TIFF reserves tag codes at or above 32768 for a vendor's own use, which is where instrument metadata ends up, whereas the baseline tags below that hold the bookkeeping needed to decode the pixels.

The Exif IFD is the exception: its fields are standard rather than a vendor's own, so they are merged in beside the rest instead of being nested behind the name of the tag that points at them.

Source code in src/biomero_converter/tiff_metadata.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def get_extra_metadata(tif: TiffFile) -> Dict[str, Any]:
    """Return the vendor metadata in a TIFF file.

    Rather than reading the tags of particular vendors, every private tag
    is collected and normalised the same way, so that vendors which are
    not known here are picked up as well. TIFF reserves tag codes at or
    above 32768 for a vendor's own use, which is where instrument
    metadata ends up, whereas the baseline tags below that hold the
    bookkeeping needed to decode the pixels.

    The Exif IFD is the exception: its fields are standard rather than a
    vendor's own, so they are merged in beside the rest instead of being
    nested behind the name of the tag that points at them.
    """
    if tif.is_ome and tif.ome_metadata:
        return repair_text(decode_metadata(tif.ome_metadata))

    extra_metadata = {}
    # setdefault lets the first page win, as later pages tend to be
    # thumbnails or reduced resolutions; they only fill in missing fields
    for page in tif.pages:
        for tag in page.tags.values():
            if tag.code >= PRIVATE_TAG_CODE:
                value = decode_metadata(tag.value)
                if value in (None, '', {}):
                    continue
                if tag.name == EXIF_TAG_NAME and isinstance(value, dict):
                    for name, field in value.items():
                        extra_metadata.setdefault(name, field)
                else:
                    # key by tag name, so that different vendor tags in one
                    # file are kept side by side
                    extra_metadata.setdefault(tag.name, value)
            elif tag.name in IDENTITY_TAG_NAMES:
                extra_metadata.setdefault(tag.name, tag.value)
    return repair_text(extra_metadata)

get_pixel_size_um(tif, metadata, shape)

Return the size of a pixel in micrometres, per axis.

The sources are tried in the order they can be trusted: what a vendor states outright, then the width of the image divided by the pixels across it, and last the baseline TIFF resolution, which is the one every writer fills in whether it knows the answer or not.

Source code in src/biomero_converter/tiff_metadata.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def get_pixel_size_um(tif: TiffFile, metadata: Dict,
                      shape: Dict[str, int]) -> Dict[str, float]:
    """Return the size of a pixel in micrometres, per axis.

    The sources are tried in the order they can be trusted: what a
    vendor states outright, then the width of the image divided by the
    pixels across it, and last the baseline TIFF resolution, which is
    the one every writer fills in whether it knows the answer or not.
    """
    pixel_size = {}
    if tif.is_imagej and tif.imagej_metadata:
        pixel_size.update(imagej_pixel_size(tif))
    for source in (stated_pixel_size, field_of_view_pixel_size):
        for axis, size in source(metadata, shape).items():
            pixel_size.setdefault(axis, size)
    for axis, size in resolution_pixel_size(tif).items():
        pixel_size.setdefault(axis, size)
    return {axis: size for axis, size in pixel_size.items()
            if is_plausible_pixel_size(size)}

get_position_um(metadata)

Return the stage position in micrometres, per axis.

A vendor writes the position as a structure named after the stage, holding one field per axis, or as flat fields with the axis on the end of the name. Where no unit is given the value is read as metres, unless the file says how far the stage travels.

Source code in src/biomero_converter/tiff_metadata.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
def get_position_um(metadata: Dict) -> Dict[str, float]:
    """Return the stage position in micrometres, per axis.

    A vendor writes the position as a structure named after the stage,
    holding one field per axis, or as flat fields with the axis on the
    end of the name. Where no unit is given the value is read as metres,
    unless the file says how far the stage travels.
    """
    position = {}
    travel_mm = stage_travel_mm(metadata)
    for key, value, siblings in walk_fields(metadata):
        name, axis, _ = split_axis(normalise_name(key))
        if isinstance(value, dict) and name.endswith(
                POSITION_CONTAINER_NAMES) and axis is None:
            for axis_key, axis_value in value.items():
                axis = normalise_name(axis_key)
                if axis in ('x', 'y', 'z'):
                    coordinate = parse_quantity(
                        axis_value,
                        implied_position_unit(axis_value, travel_mm))
                    if coordinate is not None:
                        position.setdefault(axis, coordinate)
        elif axis is not None and name.endswith(POSITION_NAMES):
            coordinate = parse_quantity(
                value, sibling_unit(key, siblings)
                or implied_position_unit(value, travel_mm))
            if coordinate is not None:
                position.setdefault(axis, coordinate)
    return position

imagej_pixel_size(tif)

Return the pixel size ImageJ writes, which names its own unit.

Source code in src/biomero_converter/tiff_metadata.py
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
def imagej_pixel_size(tif: TiffFile) -> Dict:
    """Return the pixel size ImageJ writes, which names its own unit."""
    metadata = tif.imagej_metadata
    unit = metadata.get('unit', '')
    pixel_size = {}
    for axis, name in (('x', 'XResolution'), ('y', 'YResolution')):
        value = tif.pages.first.tags.valueof(name)
        if isinstance(value, tuple) and all(value):
            size = convert_to_um(value[1] / value[0], unit)
            if size is not None:
                pixel_size[axis] = size
    spacing = convert_to_um(metadata.get('spacing'), unit)
    if spacing is not None:
        pixel_size['z'] = spacing
    return pixel_size

implied_position_unit(value, travel_mm)

Return the unit a position with no unit beside it is in.

A vendor leaving the unit out means the SI one, except where the file also names the travel of its stage: a coordinate that fits inside that travel read as millimetres is millimetres, because no stage of that size reaches metres away from its own origin.

Source code in src/biomero_converter/tiff_metadata.py
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def implied_position_unit(value: Any,
                          travel_mm: Optional[float]) -> str:
    """Return the unit a position with no unit beside it is in.

    A vendor leaving the unit out means the SI one, except where the
    file also names the travel of its stage: a coordinate that fits
    inside that travel read as millimetres is millimetres, because no
    stage of that size reaches metres away from its own origin.
    """
    if travel_mm is None:
        return 'm'
    if isinstance(value, dict):
        keyed = {normalise_name(key): item for key, item in value.items()}
        value = next((keyed[key] for key in VALUE_KEYS if key in keyed), None)
    try:
        magnitude = abs(float(value))
    except (TypeError, ValueError):
        return 'm'
    return 'mm' if magnitude <= travel_mm else 'm'

implied_unit(name)

Return the unit a pixel size with no unit beside it is in.

OME states that PhysicalSize defaults to micrometres. Anywhere else a bare length is in the SI unit: Olympus writes 1.65e-9, which is metres or nothing.

Source code in src/biomero_converter/tiff_metadata.py
360
361
362
363
364
365
366
367
def implied_unit(name: str) -> str:
    """Return the unit a pixel size with no unit beside it is in.

    OME states that PhysicalSize defaults to micrometres. Anywhere else
    a bare length is in the SI unit: Olympus writes 1.65e-9, which is
    metres or nothing.
    """
    return 'um' if name.endswith('physicalsize') else 'm'

is_document_detail(key, value)

Return whether an entry describes the document, not the image.

Source code in src/biomero_converter/tiff_metadata.py
129
130
131
132
def is_document_detail(key: Any, value: Any) -> bool:
    """Return whether an entry describes the document, not the image."""
    return ((isinstance(key, str) and key.startswith(XSI_NAMESPACE))
            or (isinstance(value, str) and '.xsd' in value.lower()))

is_plausible_pixel_size(size)

Return whether a pixel size can be a measurement at all.

Source code in src/biomero_converter/tiff_metadata.py
333
334
335
336
def is_plausible_pixel_size(size: Any) -> bool:
    """Return whether a pixel size can be a measurement at all."""
    low, high = PLAUSIBLE_PIXEL_SIZE_UM
    return isinstance(size, float) and low <= size <= high

normalise_name(key)

Return a key as bare lowercase letters, for comparing names.

Source code in src/biomero_converter/tiff_metadata.py
219
220
221
222
223
224
def normalise_name(key: Any) -> str:
    """Return a key as bare lowercase letters, for comparing names."""
    if not isinstance(key, str):
        return ''
    return ''.join(character for character in key.lower()
                   if character.isalnum())

parse_quantity(value, unit=None)

Return a metadata value in micrometres, however it is written.

A vendor writes a measurement as a number beside a unit field, as a number with the unit stuck on the end of the string, or as a bare number whose unit is named by a neighbouring field.

Source code in src/biomero_converter/tiff_metadata.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def parse_quantity(value: Any, unit: Any = None) -> Optional[float]:
    """Return a metadata value in micrometres, however it is written.

    A vendor writes a measurement as a number beside a unit field, as a
    number with the unit stuck on the end of the string, or as a bare
    number whose unit is named by a neighbouring field.
    """
    if isinstance(value, dict):
        keyed = {normalise_name(key): item for key, item in value.items()}
        for value_key in VALUE_KEYS:
            if value_key in keyed:
                return parse_quantity(keyed[value_key],
                                      find_unit(keyed) or unit)
        return None
    if isinstance(value, str):
        match = QUANTITY_PATTERN.match(repair_text(value))
        if match is None:
            return None
        number, written_unit = match.groups()
        return convert_to_um(number, written_unit or unit)
    return convert_to_um(value, unit)

parse_xml(value)

Return value parsed as an xml document, or None if it is not one.

The xml declaration is optional, and vendors do leave it out, so rather than looking for one, hand anything that opens like a document to the parser and let it decide.

Source code in src/biomero_converter/tiff_metadata.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
def parse_xml(value: str) -> Optional[Dict]:
    """Return value parsed as an xml document, or None if it is not one.

    The xml declaration is optional, and vendors do leave it out, so
    rather than looking for one, hand anything that opens like a
    document to the parser and let it decide.
    """
    if not value.lstrip().startswith('<'):
        return None
    try:
        return xml2dict(value)
    except ParseError:
        return None

repair_text(value)

Recursively put back the characters a broken encoding garbled.

Only the sequences known to be garbled are replaced, rather than guessing at the encoding of every string, which would mangle text that was read correctly.

Source code in src/biomero_converter/tiff_metadata.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def repair_text(value: Any) -> Any:
    """Recursively put back the characters a broken encoding garbled.

    Only the sequences known to be garbled are replaced, rather than
    guessing at the encoding of every string, which would mangle text
    that was read correctly.
    """
    if isinstance(value, str):
        for garbled, meant in MOJIBAKE.items():
            value = value.replace(garbled, meant)
        return value
    if isinstance(value, dict):
        return {key: repair_text(item) for key, item in value.items()}
    if isinstance(value, list):
        return [repair_text(item) for item in value]
    return value

resolution_pixel_size(tif)

Return the pixel size from the baseline TIFF resolution.

The resolution is a rational, and a writer with nothing to say fills it in anyway, so read it only where it can mean something.

Source code in src/biomero_converter/tiff_metadata.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def resolution_pixel_size(tif: TiffFile) -> Dict:
    """Return the pixel size from the baseline TIFF resolution.

    The resolution is a rational, and a writer with nothing to say
    fills it in anyway, so read it only where it can mean something.
    """
    pixel_size = {}
    tags = tif.pages.first.tags
    unit = getattr(tags.valueof('ResolutionUnit'), 'name', '')
    for axis, name in (('x', 'XResolution'), ('y', 'YResolution')):
        value = tags.valueof(name)
        if not isinstance(value, tuple) or not all(value):
            continue
        numerator, denominator = value
        if numerator == denominator:
            # the rational cancels out, so the ratio says nothing and
            # only the numerator is left to read. A resolution that
            # large is pixels per metre, whatever the unit tag claims,
            # which is filled in by default as readily as the value.
            size = convert_to_um(1 / numerator, 'm')
        elif unit == 'CENTIMETER':
            size = convert_to_um(denominator / numerator, 'cm')
        elif unit == 'INCH' and numerator / denominator not in (
                SCREEN_RESOLUTIONS):
            size = convert_to_um(denominator / numerator, 'inch')
        else:
            # no unit at all, or one of the resolutions a writer fills
            # in to say how large to print the image on a screen
            size = None
        if size is not None:
            pixel_size[axis] = size
    return pixel_size

sibling_unit(key, siblings)

Return the unit a neighbouring field names for this one.

OME writes PhysicalSizeX beside PhysicalSizeXUnit, which is the same name with the unit on the end.

Source code in src/biomero_converter/tiff_metadata.py
297
298
299
300
301
302
303
304
305
306
307
308
309
def sibling_unit(key: str, siblings: Dict) -> Optional[str]:
    """Return the unit a neighbouring field names for this one.

    OME writes PhysicalSizeX beside PhysicalSizeXUnit, which is the same
    name with the unit on the end.
    """
    for unit_key in UNIT_KEYS:
        for sibling, value in siblings.items():
            if (isinstance(value, str)
                    and normalise_name(sibling) == normalise_name(key)
                    + unit_key):
                return value
    return None

split_axis(name)

Split the axis off the end of a normalised field name.

The suffix itself comes back as well, because a name means different things depending on how the axis was written.

Source code in src/biomero_converter/tiff_metadata.py
227
228
229
230
231
232
233
234
235
236
def split_axis(name: str) -> Tuple[str, Optional[str], str]:
    """Split the axis off the end of a normalised field name.

    The suffix itself comes back as well, because a name means
    different things depending on how the axis was written.
    """
    for suffix, axis in AXIS_SUFFIXES:
        if name.endswith(suffix) and name != suffix:
            return name[:-len(suffix)], axis, suffix
    return name, None, ''

stage_travel_mm(metadata)

Return how far the stage travels, where a field names it.

A vendor writes the travel into the model of the stage, as in TMCM-GYLZ-4000X-FIVE-110x110x69, and quotes it in millimetres.

Source code in src/biomero_converter/tiff_metadata.py
446
447
448
449
450
451
452
453
454
455
456
457
458
def stage_travel_mm(metadata: Dict) -> Optional[float]:
    """Return how far the stage travels, where a field names it.

    A vendor writes the travel into the model of the stage, as in
    TMCM-GYLZ-4000X-FIVE-110x110x69, and quotes it in millimetres.
    """
    travels = []
    for key, value, _ in walk_fields(metadata):
        if 'stage' not in normalise_name(key) or not isinstance(value, str):
            continue
        for match in STAGE_TRAVEL_PATTERN.finditer(value):
            travels.extend(float(group) for group in match.groups() if group)
    return max(travels) if travels else None

stated_pixel_size(metadata, shape)

Return the pixel size from any field naming one.

This is where OME PhysicalSizeX, and every vendor field named after the size or the spacing of a pixel, is picked up.

Source code in src/biomero_converter/tiff_metadata.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def stated_pixel_size(metadata: Dict, shape: Dict[str, int]) -> Dict:
    """Return the pixel size from any field naming one.

    This is where OME PhysicalSizeX, and every vendor field named after
    the size or the spacing of a pixel, is picked up.
    """
    pixel_size = {}
    for key, value, siblings in walk_fields(metadata):
        name, axis, suffix = split_axis(normalise_name(key))
        if not (name.endswith(PIXEL_SIZE_NAMES)
                or (name.endswith('pixel') and suffix in DIMENSION_SUFFIXES)):
            continue
        size = parse_quantity(value, sibling_unit(key, siblings)
                              or implied_unit(name))
        if size is None:
            continue
        for each_axis in (axis,) if axis else ('x', 'y'):
            pixel_size.setdefault(each_axis, size)
    return pixel_size

walk_fields(metadata)

Yield every field in the metadata, with the mapping holding it.

The mapping comes along so that a field can be read together with its neighbours, which is where the unit of a bare number is named.

Source code in src/biomero_converter/tiff_metadata.py
282
283
284
285
286
287
288
289
290
291
292
293
294
def walk_fields(metadata: Any) -> Iterator[Tuple[str, Any, Dict]]:
    """Yield every field in the metadata, with the mapping holding it.

    The mapping comes along so that a field can be read together with
    its neighbours, which is where the unit of a bare number is named.
    """
    if isinstance(metadata, dict):
        for key, value in metadata.items():
            yield key, value, metadata
            yield from walk_fields(value)
    elif isinstance(metadata, list):
        for item in metadata:
            yield from walk_fields(item)

util

camel_to_snake(name)

Source code in src/biomero_converter/util.py
303
304
305
def camel_to_snake(name):
    name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
    return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()

camel_to_snake_keys_dict(dct)

Source code in src/biomero_converter/util.py
295
296
297
298
299
300
def camel_to_snake_keys_dict(dct):
    if isinstance(dct, dict):
        result = {camel_to_snake(key): camel_to_snake_keys_dict(value) for key, value in dct.items()}
    else:
        result = dct
    return result

convert_dotnet_ticks_to_datetime(net_ticks)

Source code in src/biomero_converter/util.py
175
176
def convert_dotnet_ticks_to_datetime(net_ticks):
    return datetime(1, 1, 1) + timedelta(microseconds=net_ticks // 10)

convert_to_s(value, unit)

Source code in src/biomero_converter/util.py
319
320
321
322
323
324
def convert_to_s(value, unit):
    conversions = {
        'ns': 1e-9, 'µs': 1e-6, 'us': 1e-6, 'ms': 1e-3,
        's': 1, 'min': 60, 'h': 3600, 'd': 86400
    }
    return value * conversions.get(unit, 1)

convert_to_um(value, unit)

Source code in src/biomero_converter/util.py
308
309
310
311
312
313
314
315
316
def convert_to_um(value, unit):
    conversions = {
        'nm': 1e-3, 'nanometer': 1e-3,
        'µm': 1, 'um': 1, 'micrometer': 1, 'micron': 1,
        'mm': 1e3, 'millimeter': 1e3,
        'cm': 1e4, 'centimeter': 1e4,
        'm': 1e6, 'meter': 1e6
    }
    return value * conversions.get(unit, 1)

dicom_to_dict(obj)

Source code in src/biomero_converter/util.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def dicom_to_dict(obj):
    # Recursively convert a pydicom Dataset (or element value) to plain python types, using duck typing:
    # Dataset -> dict, Sequence / MultiValue -> list, DS / IS -> float / int, PersonName / UID -> str
    if hasattr(obj, 'iterall'):
        # Dataset: iterating yields its (top-level) data elements
        return {elem.keyword: dicom_to_dict(elem.value) for elem in obj
                if elem.keyword and elem.keyword != 'PixelData'}
    if obj is None or isinstance(obj, (bool, bytes)):
        return obj
    if isinstance(obj, int):
        return int(obj)
    if isinstance(obj, (float, Decimal)):
        return float(obj)
    if isinstance(obj, str):
        return str(obj)
    if isinstance(obj, Sequence):
        return [dicom_to_dict(item) for item in obj]
    return str(obj)

ensure_list(item)

Source code in src/biomero_converter/util.py
11
12
13
14
def ensure_list(item):
    if not isinstance(item, (list, tuple)):
        item = [item]
    return item

fix_bad_micro_value(value)

Source code in src/biomero_converter/util.py
365
366
367
368
def fix_bad_micro_value(value):
    if isinstance(value, str) and '\xa6\xcc' in value:
        value = value.replace('\xa6\xcc', '\xb5')
    return value

flatten_dict(dct, prefix='')

Source code in src/biomero_converter/util.py
281
282
283
284
285
286
287
288
289
290
291
292
def flatten_dict(dct, prefix=''):
    flat_dct = {}
    for key, value in dct.items():
        full_key = prefix + ':' + key if prefix else key
        if isinstance(value, dict):
            flat_dct.update(flatten_dict(value, full_key))
        elif isinstance(value, (list, tuple)):
            for index, item in enumerate(value):
                flat_dct.update(flatten_dict({str(index): item}, full_key))
        else:
            flat_dct[full_key] = value
    return flat_dct

get_bits_type(nbits)

Source code in src/biomero_converter/util.py
77
78
79
80
81
82
83
84
85
86
def get_bits_type(nbits):
    if nbits <= 8:
        dtype = np.uint8
    elif nbits <= 16:
        dtype = np.uint16
    elif nbits <= 32:
        dtype = np.uint32
    else:
        dtype = np.uint64
    return np.dtype(dtype)

get_filetitle(filename)

Source code in src/biomero_converter/util.py
93
94
def get_filetitle(filename):
    return os.path.basename(os.path.splitext(filename)[0])

get_level_from_scale(source_scales, target_scale=1)

Source code in src/biomero_converter/util.py
67
68
69
70
71
72
73
74
def get_level_from_scale(source_scales, target_scale=1):
    best_level_scale = 0, target_scale
    for level, scale in enumerate(source_scales):
        if np.isclose(scale, target_scale, rtol=1e-4):
            return level, 1
        if scale <= target_scale:
            best_level_scale = level, target_scale / scale
    return best_level_scale

get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size)

Source code in src/biomero_converter/util.py
43
44
45
46
47
48
49
50
51
52
53
54
55
def get_numpy_data(data, dim_order, t, c, z, y, x, y_size, x_size):
    x_index = dim_order.index('x')
    y_index = dim_order.index('y')
    slices = [slice(None)] * len(dim_order)
    if 't' in dim_order:
        slices[dim_order.index('t')] = t
    if 'c' in dim_order and c is not None:  # None: all channels
        slices[dim_order.index('c')] = c
    if 'z' in dim_order:
        slices[dim_order.index('z')] = z
    slices[y_index] = slice(y, y + y_size)
    slices[x_index] = slice(x, x + x_size)
    return data[tuple(slices)]

get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1)

Source code in src/biomero_converter/util.py
58
59
60
61
62
63
64
def get_pyramid_levels(shape, dim_order, max_levels, downscale, min_level_size=1):
    # limit number of pyramid levels, keeping the smallest level at least min_level_size pixels
    min_size = min(shape[dim_order.index('x')], shape[dim_order.index('y')])
    levels = 0
    while levels < max_levels and min_size / downscale ** (levels + 1) >= min_level_size:
        levels += 1
    return levels

get_rows_cols_plate(nwells)

Source code in src/biomero_converter/util.py
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_rows_cols_plate(nwells):
    nrows_cols = {
        6: (2, 3),
        12: (3, 4),
        24: (4, 6),
        48: (6, 8),
        96: (8, 12),
        384: (16, 24)
    }
    nrows, ncols = nrows_cols[nwells]
    rows = [chr(ord('A') + i) for i in range(nrows)]
    cols = [str(i + 1) for i in range(ncols)]
    return rows, cols

pad_leading_zero(input_string, num_digits=2)

Source code in src/biomero_converter/util.py
129
130
131
132
133
134
135
136
137
138
139
def pad_leading_zero(input_string, num_digits=2):
    output = str(input_string)
    is_well = not output[0].isdigit()
    if is_well:
        row, col = split_well_name(output, remove_leading_zeros=True)
        output = str(col)
    while len(output) < num_digits:
        output = '0' + output
    if is_well:
        output = row + output
    return output

parse_datetime(value)

A datetime from an ISO 8601 or other common date text, or None if it is none.

Source code in src/biomero_converter/util.py
162
163
164
165
166
167
168
169
170
171
172
def parse_datetime(value):
    """A datetime from an ISO 8601 or other common date text, or None if it is none."""
    if not value or isinstance(value, datetime):
        return value or None
    try:
        return datetime.fromisoformat(str(value))
    except ValueError:
        try:
            return dateutil.parser.parse(str(value))
        except (ValueError, OverflowError):
            return None

print_dict(value, tab=0, max_len=250, bullet=False)

Source code in src/biomero_converter/util.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def print_dict(value, tab=0, max_len=250, bullet=False):
    s = ''
    if isinstance(value, dict):
        for key, subvalue in value.items():
            s += '\n'
            if bullet:
                s += '-'
                bullet = False
            s += '\t' * tab + str(key) + ': '
            if isinstance(subvalue, dict):
                s += print_dict(subvalue, tab+1)
            elif isinstance(subvalue, list):
                for v in subvalue:
                    s += print_dict(v, tab+1, bullet=True)
            else:
                subvalue = str(subvalue)
                if len(subvalue) > max_len:
                    subvalue = subvalue[:max_len] + '...'
                s += subvalue
    else:
        s += str(value) + ' '
    return s

print_hbytes(nbytes)

Source code in src/biomero_converter/util.py
351
352
353
354
355
356
357
358
359
360
361
362
def print_hbytes(nbytes):
    exps = ['', 'K', 'M', 'G', 'T', 'P', 'E']
    div = 1024
    exp = 0
    while nbytes > div:
        nbytes /= div
        exp += 1
    if exp < len(exps):
        e = exps[exp]
    else:
        e = f'e{exp * 3}'
    return f'{nbytes:.1f}{e}B'

redimension_data(data, old_order, new_order, **indices)

Source code in src/biomero_converter/util.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def redimension_data(data, old_order, new_order, **indices):
    # able to provide optional dimension values e.g. t=0, z=0
    if new_order == old_order:
        return data

    new_data = data
    order = old_order
    # remove
    for o in old_order:
        if o not in new_order:
            index = order.index(o)
            dim_value = indices.get(o, 0)
            new_data = np.take(new_data, indices=dim_value, axis=index)
            order = order[:index] + order[index + 1:]
    # add
    for o in new_order:
        if o not in order:
            new_data = np.expand_dims(new_data, 0)
            order = o + order
    # move
    old_indices = [order.index(o) for o in new_order]
    new_indices = list(range(len(new_order)))
    new_data = np.moveaxis(new_data, old_indices, new_indices)
    return new_data

remove_key_prefix(obj, prefix)

Source code in src/biomero_converter/util.py
271
272
273
274
275
276
277
278
def remove_key_prefix(obj, prefix):
    # recursively remove prefix from dict keys
    if isinstance(obj, dict):
        return {(key[len(prefix):] if key.startswith(prefix) and len(key) > len(prefix) else key):
                remove_key_prefix(value, prefix) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [remove_key_prefix(item, prefix) for item in obj]
    return obj

split_well_name(well_name, remove_leading_zeros=True, col_as_int=False)

Source code in src/biomero_converter/util.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def split_well_name(well_name, remove_leading_zeros=True, col_as_int=False):
    matches = re.findall(r'(\D+)(\d+)', well_name)
    if len(matches) > 0:
        row, col = matches[0]
        if col_as_int or remove_leading_zeros:
            try:
                col = int(col)
            except ValueError:
                pass
        if not col_as_int:
            col = str(col)
        return row, col
    else:
        raise ValueError(f"Invalid well name format: {well_name}. Expected format like 'A1', 'B2', etc.")

splitall(path)

Source code in src/biomero_converter/util.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def splitall(path):
    allparts = []
    while True:
        parts = os.path.split(path)
        if parts[0] == path:  # sentinel for absolute paths
            allparts.insert(0, parts[0])
            break
        elif parts[1] == path: # sentinel for relative paths
            allparts.insert(0, parts[1])
            break
        else:
            path = parts[0]
            allparts.insert(0, parts[1])
    return allparts

strip_leading_zeros(well_name)

Source code in src/biomero_converter/util.py
142
143
144
def strip_leading_zeros(well_name):
    row, col = split_well_name(well_name, remove_leading_zeros=True)
    return f'{row}{col}'

to_plain_types(obj)

Source code in src/biomero_converter/util.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def to_plain_types(obj):
    # Recursively convert metadata to plain python types (e.g. for yaml): tuple -> list, numpy -> python,
    # bytes -> str, Enum -> name; datetime is kept, anything else unknown becomes str
    if isinstance(obj, dict):
        return {str(key): to_plain_types(value) for key, value in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [to_plain_types(item) for item in obj]
    if isinstance(obj, np.ndarray):
        return to_plain_types(obj.tolist())
    if isinstance(obj, np.generic):
        return obj.item()
    if isinstance(obj, bytes):
        return obj.decode('utf-8', errors='replace')
    if isinstance(obj, Enum):
        return obj.name
    if obj is None or isinstance(obj, (bool, int, float, str, datetime)):
        return obj
    return str(obj)

validate_filename(filename)

Source code in src/biomero_converter/util.py
89
90
def validate_filename(filename):
    return re.sub(r'[^\w_.)(-]', '_', filename)

without_keys(value, keys)

Source code in src/biomero_converter/util.py
262
263
264
265
266
267
268
def without_keys(value, keys):
    # recursively drop the entries named by keys
    if isinstance(value, dict):
        return {key: without_keys(item, keys) for key, item in value.items() if key not in keys}
    if isinstance(value, (list, tuple)):
        return [without_keys(item, keys) for item in value]
    return value

xml_content_to_dict(element)

Source code in src/biomero_converter/util.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def xml_content_to_dict(element):
    key = element.tag
    children = list(element)
    if key == 'Array':
        res = [xml_content_to_dict(child) for child in children]
        return res
    if len(children) > 0:
        if children[0].tag == 'Array':
            value = []
        else:
            value = {}
        for child in children:
            child_value = xml_content_to_dict(child)
            if isinstance(child_value, list):
                value.extend(child_value)
            else:
                value |= child_value
    else:
        value = element.text
        if value is not None:
            if '"' in value:
                value = value.replace('"', '')
            else:
                for t in (float, int, bool):
                    try:
                        if t == bool:
                            if value.lower() == 'true':
                                value = True
                            if value.lower() == 'false':
                                value = False
                        else:
                            value = t(value)
                        break
                    except (TypeError, ValueError):
                        pass

    if key == 'DataObject':
        key = element.attrib['ObjectType']
    if key == 'Attribute':
        key = element.attrib['Name']
    return {key: value}

version

REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) module-attribute

get_version()

Get the version from the GitHub release tag.

Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image), otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag), otherwise the version of the installed package (taken from the tag when it was built).

Returns:

Name Type Description
str

Version string, or 'unknown' if it cannot be determined.

Source code in src/biomero_converter/version.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def get_version():
    """
    Get the version from the GitHub release tag.

    Uses the BIOMERO_CONVERTER_VERSION environment variable if set (e.g. in the Docker image),
    otherwise the git tag of the source tree (e.g. 'v0.1.31', or 'v0.1.31-2-gabc1234' after the tag),
    otherwise the version of the installed package (taken from the tag when it was built).

    Returns:
        str: Version string, or 'unknown' if it cannot be determined.
    """
    version = os.environ.get('BIOMERO_CONVERTER_VERSION')
    if version:
        return version
    # only this repo's own checkout: an installed package may sit inside another git repo
    if os.path.exists(os.path.join(REPO_DIR, '.git')):
        try:
            return subprocess.run(['git', 'describe', '--tags', '--always'], cwd=REPO_DIR,
                                  capture_output=True, text=True, check=True, timeout=10).stdout.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    try:
        return 'v' + package_version('biomero-converter')
    except PackageNotFoundError:
        return 'unknown'

zarr_extension

ZarrCrate

Bases: ROCrate

Source code in src/biomero_converter/zarr_extension.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ZarrCrate(ROCrate):
    def __init__(
        self, source=None, gen_preview=False, init=False, exclude=None
    ) -> None:
        super().__init__(source, gen_preview, init, exclude)
        self.metadata.extra_terms = {
            "organism_classification": "https://schema.org/taxonomicRange",
            "BioChemEntity": "https://schema.org/BioChemEntity",
            "channel": "https://www.openmicroscopy.org/Schemas/Documentation/Generated/OME-2016-06/ome_xsd.html#Channel",
            "obo": "http://purl.obolibrary.org/obo/",
            "FBcv": "http://ontobee.org/ontology/FBcv/",
            "acquisition_method": {
                "@reverse": "https://schema.org/result",
                "@type": "@id",
            },
            "biological_entity": "https://schema.org/about",
            "biosample": "http://purl.obolibrary.org/obo/OBI_0002648",
            "preparation_method": "https://www.wikidata.org/wiki/Property:P1537",
            "specimen": "http://purl.obolibrary.org/obo/HSO_0000308",
        }

__init__(source=None, gen_preview=False, init=False, exclude=None)

Source code in src/biomero_converter/zarr_extension.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def __init__(
    self, source=None, gen_preview=False, init=False, exclude=None
) -> None:
    super().__init__(source, gen_preview, init, exclude)
    self.metadata.extra_terms = {
        "organism_classification": "https://schema.org/taxonomicRange",
        "BioChemEntity": "https://schema.org/BioChemEntity",
        "channel": "https://www.openmicroscopy.org/Schemas/Documentation/Generated/OME-2016-06/ome_xsd.html#Channel",
        "obo": "http://purl.obolibrary.org/obo/",
        "FBcv": "http://ontobee.org/ontology/FBcv/",
        "acquisition_method": {
            "@reverse": "https://schema.org/result",
            "@type": "@id",
        },
        "biological_entity": "https://schema.org/about",
        "biosample": "http://purl.obolibrary.org/obo/OBI_0002648",
        "preparation_method": "https://www.wikidata.org/wiki/Property:P1537",
        "specimen": "http://purl.obolibrary.org/obo/HSO_0000308",
    }