diff --git a/model_server/base/accessors.py b/model_server/base/accessors.py index b250605d4df5de6b1cd18d3a2570a85ed532f319..43d400f9587253b4e2fd2fc62a59ceb1acd39857 100644 --- a/model_server/base/accessors.py +++ b/model_server/base/accessors.py @@ -24,6 +24,10 @@ class GenericImageDataAccessor(ABC): """ pass + @property + def loaded(self): + return self._data is not None + @property def chroma(self): return self.shape_dict['C'] @@ -38,6 +42,7 @@ class GenericImageDataAccessor(ABC): def is_3d(self): return True if self.shape_dict['Z'] > 1 else False + # TODO: no direct calls to self._data outside load/unload methods def is_mask(self): return is_mask(self._data) @@ -198,7 +203,7 @@ class InMemoryDataAccessor(GenericImageDataAccessor): self._data = self.conform_data(data) class GenericImageFileAccessor(GenericImageDataAccessor): # image data is loaded from a file - def __init__(self, fpath: Path): + def __init__(self, fpath: Path, lazy=False): """ Interface for image data that originates in an image file :param fpath: absolute path to image file @@ -208,6 +213,15 @@ class GenericImageFileAccessor(GenericImageDataAccessor): # image data is loaded raise FileAccessorError(f'Could not find file at {fpath}') self.fpath = fpath + if not lazy: + self._data = self.load() + else: + self._data = None + + @abstractmethod + def load(self): + pass + @staticmethod def read(fp: Path): return generate_file_accessor(fp) @@ -219,12 +233,12 @@ class GenericImageFileAccessor(GenericImageDataAccessor): # image data is loaded return d class TifSingleSeriesFileAccessor(GenericImageFileAccessor): - def __init__(self, fpath: Path): - super().__init__(fpath) + def load(self): + fpath = self.fpath try: tf = tifffile.TiffFile(fpath) - self.tf = tf + self.tf = tf # TODO: close file connection except Exception: raise FileAccessorError(f'Unable to access data in {fpath}') @@ -251,14 +265,15 @@ class TifSingleSeriesFileAccessor(GenericImageFileAccessor): [0, 1, 2, 3] ) - self._data = self.conform_data(yxcz.reshape(yxcz.shape[0:4])) + return self.conform_data(yxcz.reshape(yxcz.shape[0:4])) + # TODO: remove def __del__(self): self.tf.close() class PngFileAccessor(GenericImageFileAccessor): - def __init__(self, fpath: Path): - super().__init__(fpath) + def load(self): + fpath = self.fpath try: arr = imread(fpath) @@ -266,19 +281,19 @@ class PngFileAccessor(GenericImageFileAccessor): FileAccessorError(f'Unable to access data in {fpath}') if len(arr.shape) == 3: # rgb - self._data = np.expand_dims(arr, 3) + return np.expand_dims(arr, 3) else: # mono - self._data = np.expand_dims(arr, (2, 3)) + return np.expand_dims(arr, (2, 3)) class CziImageFileAccessor(GenericImageFileAccessor): """ - Image that is stored in a Zeiss .CZI file; may be multi-channel, and/or a z-stack, - but not a time series or multiposition acquisition. + Image that is stored in a Zeiss .CZI file; may be multi-channel, and/or a z-stack, but not a time series + or multiposition acquisition. Read the whole file as a single accessor, no interaction with subblocks. """ - def __init__(self, fpath: Path): - super().__init__(fpath) - + def load(self): + fpath = self.fpath try: + # TODO: persist metadata then remove file connection cf = czifile.CziFile(fpath) self.czifile = cf except Exception: @@ -302,7 +317,7 @@ class CziImageFileAccessor(GenericImageFileAccessor): [cf.axes.index(ch) for ch in idx], [0, 1, 2, 3] ) - self._data = self.conform_data(yxcz.reshape(yxcz.shape[0:4])) + return self.conform_data(yxcz.reshape(yxcz.shape[0:4])) def __del__(self): self.czifile.close() @@ -363,21 +378,21 @@ def write_accessor_data_to_file(fpath: Path, acc: GenericImageDataAccessor, mkdi return True -def generate_file_accessor(fpath): +def generate_file_accessor(fpath, **kwargs): """ Given an image file path, return an image accessor, assuming the file is a supported format and represents a single position array, which may be single or multichannel, single plane or z-stack. """ if str(fpath).upper().endswith('.TIF') or str(fpath).upper().endswith('.TIFF'): - return TifSingleSeriesFileAccessor(fpath) + return TifSingleSeriesFileAccessor(fpath, **kwargs) elif str(fpath).upper().endswith('.CZI'): - return CziImageFileAccessor(fpath) + return CziImageFileAccessor(fpath, **kwargs) elif str(fpath).upper().endswith('.PNG'): - return PngFileAccessor(fpath) + return PngFileAccessor(fpath, **kwargs) else: raise FileAccessorError(f'Could not match a file accessor with {fpath}') - +# TODO: implement lazy loading at patch stack level class PatchStack(InMemoryDataAccessor): axes = 'PYXCZ' diff --git a/model_server/base/session.py b/model_server/base/session.py index 1dfd8f3078d9adba0f103e776821f3691d2adb47..5f8c6611e7cd476a4d4a83cc53289d6b94744635 100644 --- a/model_server/base/session.py +++ b/model_server/base/session.py @@ -163,10 +163,11 @@ class _Session(object): if accessor_id is None: idx = len(self.accessors) accessor_id = f'acc_{idx:06d}' - self.accessors[accessor_id] = {'loaded': True, 'object': acc, **acc.info} + self.accessors[accessor_id] = {'loaded': acc.loaded, 'object': acc, **acc.info} self.log_info(f'Added accessor {accessor_id}') return accessor_id + # TODO: divergent cases between lazy file-backed accessor (with its own loaded state) and in-memory ones def del_accessor(self, accessor_id: str) -> str: """ Remove accessor object but retain its info dictionary diff --git a/tests/base/test_accessors.py b/tests/base/test_accessors.py index e73e347029f5ec42cd01696ebdf01a0ae4c0cff0..f17053988cb814d83b047036f37762d33328255d 100644 --- a/tests/base/test_accessors.py +++ b/tests/base/test_accessors.py @@ -228,6 +228,9 @@ class TestCziImageFileAccess(unittest.TestCase): ) ) + def test_lazy_load(self): + cf = generate_file_accessor(data['czifile']['path']) + self.assertEqual(1, 0) class TestPatchStackAccessor(unittest.TestCase): def setUp(self) -> None: