diff --git a/README.md b/README.md index 6d64726..b037443 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ with Imread('image_file.tif', axes='cztyx') as im: ### Command line ```ndbioimage --help```: show help ```ndbioimage image```: show metadata about image -```ndbioimage image {name}.tif -r```: copy image into image.tif (replacing {name} with image), while registering channels -```ndbioimage image image.mp4 -C cyan lime red``` copy image into image.mp4 (z will be max projected), make channel colors cyan lime and red +```ndbioimage image -w {name}.tif -r```: copy image into image.tif (replacing {name} with image), while registering channels +```ndbioimage image -w image.mp4 -C cyan lime red``` copy image into image.mp4 (z will be max projected), make channel colors cyan lime and red ## Adding more formats Readers for image formats subclass AbstractReader. When an image reader is imported, Imread will diff --git a/ndbioimage/__init__.py b/ndbioimage/__init__.py index 5fe28f0..b7c3f32 100755 --- a/ndbioimage/__init__.py +++ b/ndbioimage/__init__.py @@ -65,18 +65,18 @@ class DequeDict(OrderedDict): self.maxlen = maxlen super().__init__(*args, **kwargs) - def __truncate__(self) -> None: + def __setitem__(self, *args: Any, **kwargs: Any) -> None: + super().__setitem__(*args, **kwargs) + self.truncate() + + def truncate(self) -> None: if self.maxlen is not None: while len(self) > self.maxlen: self.popitem(False) - def __setitem__(self, *args: Any, **kwargs: Any) -> None: - super().__setitem__(*args, **kwargs) - self.__truncate__() - def update(self, *args: Any, **kwargs: Any) -> None: - super().update(*args, **kwargs) - self.__truncate__() + super().update(*args, **kwargs) # type: ignore + self.truncate() def find(obj: Sequence[Any], **kwargs: Any) -> Any: @@ -249,6 +249,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): tirfangle: Optional[list[float]] gain: Optional[list[float]] pcf: Optional[list[float]] + path: Path __frame__: Callable[[int, int, int], np.ndarray] @staticmethod @@ -331,8 +332,9 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): if hasattr(self, 'close'): self.close() - def __getitem__(self, n: int | Sequence[int] | slice | type(Ellipsis) | - dict[str, int | Sequence[int] | slice | type(Ellipsis)]) -> Number | Imread | np.ndarray: + def __getitem__(self, n: int | Sequence[int] | Sequence[slice] | slice | type(Ellipsis) | + dict[str, int | Sequence[int] | Sequence[slice] | slice | type(Ellipsis)] + ) -> Number | Imread | np.ndarray: """ slice like a numpy array but return an Imread instance """ if self.isclosed: raise OSError('file is closed') @@ -380,7 +382,8 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): return new def __getstate__(self) -> dict[str: Any]: - return {key: value for key, value in self.__dict__.items() if key not in self.do_not_pickle} + return ({key: value for key, value in self.__dict__.items() if key not in self.do_not_pickle} | + {'cache_size': self.cache.maxlen}) def __len__(self) -> int: return self.shape[0] @@ -390,10 +393,10 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): def __setstate__(self, state: dict[str, Any]) -> None: """ What happens during unpickling """ - self.__dict__.update(state) + self.__dict__.update({key: value for key, value in state.items() if key != 'cache_size'}) if isinstance(self, AbstractReader): self.open() - self.cache = DequeDict(16) + self.cache = DequeDict(state.get('cache_size', 16)) def __str__(self) -> str: return str(self.path) @@ -509,7 +512,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): yxczt = (slice(None), slice(None)) + idx in_idx = tuple(yxczt['yxczt'.find(i)] for i in self.axes) w = where if where is None or isinstance(where, bool) else where[in_idx] - initials = [fun(np.asarray(ffun(self[in_idx])), initial=initial, where=w) + initials = [fun(np.asarray(ffun(self[in_idx])), initial=initial, where=w) # type: ignore for fun, ffun, initial in zip(funs, ffuns, initials)] res = cfun(*initials) res = (np.round(res) if dtype.kind in 'ui' else res).astype(p.sub('', dtype.name)) @@ -543,7 +546,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): out_idx = tuple(yxczt['yxczt'.find(i)] for i in out_axes) in_idx = tuple(yxczt['yxczt'.find(i)] for i in self.axes) w = where if where is None or isinstance(where, bool) else where[in_idx] - res = cfun(*[fun(ffun(self[in_idx]), frame_ax, initial=initial, where=w) + res = cfun(*[fun(ffun(self[in_idx]), frame_ax, initial=initial, where=w) # type: ignore for fun, ffun, initial in zip(funs, ffuns, initials)]) out[out_idx] = (np.round(res) if out.dtype.kind in 'ui' else res).astype(p.sub('', dtype.name)) else: @@ -556,12 +559,12 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): if idx['czt'.find(axis_str)] == 0: w = where if where is None or isinstance(where, bool) else (where[in_idx],) for tmp, fun, ffun, initial in zip(tmps, funs, ffuns, initials): - tmp[out_idx] = fun((ffun(self[in_idx]),), 0, initial=initial, where=w) + tmp[out_idx] = fun((ffun(self[in_idx]),), 0, initial=initial, where=w) # type: ignore else: w = where if where is None or isinstance(where, bool) else \ (np.ones_like(where[in_idx]), where[in_idx]) for tmp, fun, ffun in zip(tmps, funs, ffuns): - tmp[out_idx] = fun((tmp[out_idx], ffun(self[in_idx])), 0, where=w) + tmp[out_idx] = fun((tmp[out_idx], ffun(self[in_idx])), 0, where=w) # type: ignore out[...] = (np.round(cfun(*tmps)) if out.dtype.kind in 'ui' else cfun(*tmps)).astype(p.sub('', dtype.name)) return out @@ -606,7 +609,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): @property def size(self) -> int: - return np.prod(self.shape) + return np.prod(self.shape) # type: ignore @property def shape(self) -> Shape: @@ -665,7 +668,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): @property def T(self) -> Imread: # noqa - return self.transpose() + return self.transpose() # type: ignore @cached_property def timeseries(self) -> bool: @@ -782,7 +785,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): @wraps(np.ndarray.std) def std(self, axis=None, dtype=None, out=None, ddof=0, keepdims=None, *, where=True): - return self.var(axis, dtype, out, ddof, keepdims, where=where, std=True) + return self.var(axis, dtype, out, ddof, keepdims, where=where, std=True) # type: ignore @wraps(np.ndarray.sum) def sum(self, axis=None, dtype=None, out=None, keepdims=False, initial=None, where=True, **_): @@ -845,7 +848,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): for i, e in zip('yxczt', (y, x, c, z, t))) d = np.empty((len(y), len(x), len(c), len(z), len(t)), self.dtype) for (ci, cj), (zi, zj), (ti, tj) in product(enumerate(c), enumerate(z), enumerate(t)): - d[:, :, ci, zi, ti] = self.frame(cj, zj, tj)[y][:, x] + d[:, :, ci, zi, ti] = self.frame(cj, zj, tj)[y][:, x] # type: ignore return d def copy(self) -> View: @@ -865,14 +868,15 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): # cache last n (default 16) frames in memory for speed (~250x faster) key = (c, z, t, self.transform, self.frame_decorator) - if key in self.cache: + if self.cache.maxlen and key in self.cache: self.cache.move_to_end(key) f = self.cache[key] else: f = self.transform[self.channel_names[c], t].frame(self.__frame__(c, z, t)) if self.frame_decorator is not None: f = self.frame_decorator(self, f, c, z, t) - self.cache[key] = f + if self.cache.maxlen: + self.cache[key] = f if self.dtype is not None: return f.copy().astype(self.dtype) else: @@ -942,7 +946,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): for plane in image.pixels.planes if plane.the_c == 0 and plane.the_t == 0]) i = np.argsort(z[:, 1]) image.pixels.physical_size_z = np.nanmean(np.true_divide(*np.diff(z[i], axis=0).T)) * 1e6 - image.pixels.physical_size_z_unit = 'µm' + image.pixels.physical_size_z_unit = 'µm' # type: ignore except Exception: # noqa pass return ome @@ -1015,7 +1019,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): frame = np.dstack([255 * frame * i for i in color]) return np.clip(np.round(frame), 0, 255).astype('uint8') - ab = list(zip(*[get_ab(i) for i in self.transpose('cztyx')])) + ab = list(zip(*[get_ab(i) for i in self.transpose('cztyx')])) # type: ignore colors = colors or ('r', 'g', 'b')[:self.shape['c']] + max(0, self.shape['c'] - 3) * ('w',) brightnesses = brightnesses or (1,) * self.shape['c'] scale = scale or 1 @@ -1027,7 +1031,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): outputdict={'-vcodec': 'libx264', '-preset': 'veryslow', '-pix_fmt': 'yuv420p', '-r': '7', '-vf': f'setpts={25 / 7}*PTS,scale={shape_x}:{shape_y}:flags=neighbor'} ) as movie: - im = self.transpose('tzcyx') + im = self.transpose('tzcyx') # type: ignore for ti in tqdm(t, desc='Saving movie', disable=not bar): movie.writeFrame(np.max([cframe(yx, c, a, b / s, scale) for yx, a, b, c, s in zip(im[ti].max('z'), *ab, colors, brightnesses)], 0)) @@ -1061,7 +1065,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): n[i] = (n[i],) shape = [len(i) for i in n] - with TransformTiff(self, fname.with_suffix('.tif'), pixel_type, + with TransformTiff(self, fname.with_suffix('.tif'), dtype=pixel_type, pxsize=self.pxsize_um, deltaz=self.deltaz_um, **kwargs) as tif: for i, m in tqdm(zip(product(*[range(s) for s in shape]), product(*n)), # noqa total=np.prod(shape), desc='Saving tiff', disable=not bar): @@ -1106,6 +1110,11 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC): view.transform.adapt(view.frameoffset, view.shape.yxczt, view.channel_names) return view + def set_cache_size(self, cache_size: int) -> None: + assert isinstance(cache_size, int) and cache_size >= 0 + self.cache.maxlen = cache_size + self.cache.truncate() + @staticmethod def split_path_series(path: Path | str) -> tuple[Path, int]: if isinstance(path, str): @@ -1320,12 +1329,13 @@ def main() -> None: parser = ArgumentParser(description='Display info and save as tif') parser.add_argument('-v', '--version', action='version', version=__version__) parser.add_argument('file', help='image_file', type=str, nargs='*') - parser.add_argument('-w', '--write', help='path to tif/movie out, {folder}, {name} and {ext} take this from file in', type=str, default=None) + parser.add_argument('-w', '--write', help='path to tif/movie out, {folder}, {name} and {ext} take this from file in', + type=str, default=None) parser.add_argument('-o', '--extract_ome', help='extract ome to xml file', action='store_true') parser.add_argument('-r', '--register', help='register channels', action='store_true') parser.add_argument('-c', '--channel', help='channel', type=int, default=None) parser.add_argument('-z', '--zslice', help='z-slice', type=int, default=None) - parser.add_argument('-t', '--time', help='time', type=str, default=None) + parser.add_argument('-t', '--time', help='time (frames) in python slicing notation', type=str, default=None) parser.add_argument('-s', '--split', help='split channels', action='store_true') parser.add_argument('-f', '--force', help='force overwrite', action='store_true') parser.add_argument('-C', '--movie-colors', help='colors for channels in movie', type=str, nargs='*') diff --git a/ndbioimage/readers/cziread.py b/ndbioimage/readers/cziread.py index 6c051f6..f16f2e6 100644 --- a/ndbioimage/readers/cziread.py +++ b/ndbioimage/readers/cziread.py @@ -332,7 +332,7 @@ class OmeParse: model.Objective( id=objective.attrib['Id'], model=self.text(objective.find('Manufacturer').find('Model')), - immersion=self.text(objective.find('Immersion')), + immersion=self.text(objective.find('Immersion')), # type: ignore lens_na=float(self.text(objective.find('LensNA'))), nominal_magnification=float(self.text(objective.find('NominalMagnification'))))) @@ -410,14 +410,14 @@ class OmeParse: pixels=model.Pixels( id='Pixels:0', size_x=self.size_x, size_y=self.size_y, size_c=self.size_c, size_z=self.size_z, size_t=self.size_t, - dimension_order='XYCZT', type=pixel_type, + dimension_order='XYCZT', type=pixel_type, # type: ignore significant_bits=int(self.text(image.find('ComponentBitCount'))), - big_endian=False, interleaved=False, metadata_only=True), + big_endian=False, interleaved=False, metadata_only=True), # type: ignore experimenter_ref=model.ExperimenterRef(id='Experimenter:0'), instrument_ref=model.InstrumentRef(id='Instrument:0'), objective_settings=model.ObjectiveSettings( id=objective_settings.find('ObjectiveRef').attrib['Id'], - medium=self.text(objective_settings.find('Medium')), + medium=self.text(objective_settings.find('Medium')), # type: ignore refractive_index=float(self.text(objective_settings.find('RefractiveIndex')))), stage_label=model.StageLabel( name=f'Scene position #0', @@ -492,13 +492,13 @@ class OmeParse: model.Channel( id=f'Channel:{idx}', name=channel.attrib['Name'], - acquisition_mode=self.text(channel.find('AcquisitionMode')), + acquisition_mode=self.text(channel.find('AcquisitionMode')), # type: ignore color=model.Color(self.text(self.channels_ds[channel.attrib['Id']].find('Color'), 'white')), detector_settings=model.DetectorSettings(id=detector.attrib['Id'], binning=binning), # emission_wavelength=text(channel.find('EmissionWavelength')), # TODO: fix excitation_wavelength=light_source_settings.wavelength, filter_set_ref=model.FilterSetRef(id=self.ome.instruments[0].filter_sets[filterset_idx].id), - illumination_type=self.text(channel.find('IlluminationType')), + illumination_type=self.text(channel.find('IlluminationType')), # type: ignore light_source_settings=light_source_settings, samples_per_pixel=int(self.text(laser_scan_info.find('Averaging'))))) elif self.version in ('1.1', '1.2'): @@ -543,7 +543,7 @@ class OmeParse: model.Channel( id=f'Channel:{idx}', name=channel.attrib['Name'], - acquisition_mode=self.text(channel.find('AcquisitionMode')).replace( + acquisition_mode=self.text(channel.find('AcquisitionMode')).replace( # type: ignore 'SingleMoleculeLocalisation', 'SingleMoleculeImaging'), color=color, detector_settings=model.DetectorSettings( @@ -553,7 +553,7 @@ class OmeParse: excitation_wavelength=self.try_default(float, None, self.text(channel.find('ExcitationWavelength'))), # filter_set_ref=model.FilterSetRef(id=ome.instruments[0].filter_sets[filterset_idx].id), - illumination_type=self.text(channel.find('IlluminationType')), + illumination_type=self.text(channel.find('IlluminationType')), # type: ignore light_source_settings=light_source_settings, samples_per_pixel=samples_per_pixel)) diff --git a/ndbioimage/readers/seqread.py b/ndbioimage/readers/seqread.py index bcb5f1d..edabc17 100644 --- a/ndbioimage/readers/seqread.py +++ b/ndbioimage/readers/seqread.py @@ -92,7 +92,8 @@ class Reader(AbstractReader, ABC): pixels=model.Pixels( size_c=size_c, size_z=size_z, size_t=size_t, size_x=metadata['Info']['Width'], size_y=metadata['Info']['Height'], - dimension_order='XYCZT', type=pixel_type, physical_size_x=pxsize, physical_size_y=pxsize, + dimension_order='XYCZT', # type: ignore + type=pixel_type, physical_size_x=pxsize, physical_size_y=pxsize, physical_size_z=metadata['Info']['Summary']['z-step_um']), objective_settings=model.ObjectiveSettings(id='Objective:0'))) diff --git a/ndbioimage/readers/tifread.py b/ndbioimage/readers/tifread.py index 4587fc3..04e3f9a 100644 --- a/ndbioimage/readers/tifread.py +++ b/ndbioimage/readers/tifread.py @@ -61,7 +61,8 @@ class Reader(AbstractReader, ABC): pixels=model.Pixels( id='Pixels:0', size_c=size_c, size_z=size_z, size_t=size_t, size_x=size_x, size_y=size_y, - dimension_order='XYCZT', type=dtype, physical_size_x=pxsize, physical_size_y=pxsize), + dimension_order='XYCZT', type=dtype, # type: ignore + physical_size_x=pxsize, physical_size_y=pxsize), objective_settings=model.ObjectiveSettings(id='Objective:0'))) for c, z, t in product(range(size_c), range(size_z), range(size_t)): ome.images[0].pixels.planes.append(model.Plane(the_c=c, the_z=z, the_t=t, delta_t=interval_t * t)) diff --git a/pyproject.toml b/pyproject.toml index dbd5f39..a1f43b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ndbioimage" -version = "2024.10.0" +version = "2025.1.0" description = "Bio image reading, metadata and some affine registration." authors = ["W. Pomp "] license = "GPLv3" @@ -16,13 +16,13 @@ numpy = ">=1.20.0" pandas = "*" tifffile = "*" czifile = "2019.7.2" -tiffwrite = ">=2024.10.4" +tiffwrite = ">=2024.12.1" ome-types = ">=0.4.0" pint = "*" tqdm = "*" lxml = "*" pyyaml = "*" -parfor = ">=2024.9.2" +parfor = ">=2025.1.0" JPype1 = "*" SimpleITK-SimpleElastix = [ { version = "*", python = "<3.12" },