import pandas as pd
from pylorenzmie.lib import meshgrid
from pylorenzmie.theory import Instrument, LorenzMie
from pylorenzmie.analysis import Localizer, Feature
from pylorenzmie.analysis.Hologram import Hologram
from pylorenzmie.lib.lmtypes import Image, Results
[docs]
class Frame(Hologram):
'''Full-frame holographic microscopy analysis pipeline.
A Frame is a Hologram of the full camera field, augmented with a
:class:`~pylorenzmie.analysis.Localizer` and a shared
:class:`~pylorenzmie.theory.Instrument`. Slicing a Frame returns
a :class:`~pylorenzmie.analysis.Feature` with the correct corner
coordinates and instrument already attached.
Parameters
----------
data : numpy.ndarray, optional
Normalized hologram. Setting data clears previous results.
Default: ``None``.
corner : tuple[float, float], optional
Top-left corner of this image within the full camera frame.
Default: ``(0., 0.)``.
instrument : Instrument, optional
Microscope parameters shared by all features.
Default: ``Instrument()``.
localizer : Localizer, optional
Feature detection backend. Default: ``Localizer()``.
Attributes
----------
features : list[Feature]
Feature objects created by the most recent :meth:`detect` call.
bboxes : list
Bounding boxes ``((x0, y0), w, h)`` for current features.
results : pandas.DataFrame
Optimized parameters from the most recent :meth:`optimize` or
:meth:`analyze` call.
'''
def __init__(self,
data: Image | None = None,
corner: tuple[float, float] = (0., 0.),
instrument: Instrument | None = None,
localizer: Localizer | None = None) -> None:
self.instrument = instrument or Instrument()
self.localizer = localizer or Localizer()
self._features: list[Feature] = []
self._bboxes: list = []
self._results: pd.DataFrame = pd.DataFrame()
# Initialize Hologram-level state directly; super().__init__() is
# skipped so that data=None is valid before any image is loaded.
self.corner = corner
self._coordinates = None
self._data: Image | None = None
if data is not None:
self.data = data
# Override the dataclass-generated 'data' field with a property so
# that assigning frame.data triggers coordinate regeneration and a
# reset of any cached features and results.
@property
def data(self) -> Image | None:
'''Normalized hologram intensity.'''
return self._data
@data.setter
def data(self, data: Image | None) -> None:
self._features = []
self._results = pd.DataFrame()
self._data = data
if data is not None:
self._coordinates = meshgrid(data.shape,
corner=self.corner,
flatten=False)
else:
self._coordinates = None
@property
def shape(self) -> tuple[int, int]:
'''Image dimensions ``(ny, nx)``, or ``(0, 0)`` if no data.'''
if self._data is None:
return (0, 0)
return self._data.shape
@property
def results(self) -> Results:
'''Optimized parameters from the most recent fit.'''
return self._results
@property
def features(self) -> list[Feature]:
'''Features detected in the current frame.'''
return self._features
@property
def bboxes(self) -> list:
'''Bounding boxes of current features, each ``((x0, y0), w, h)``.'''
return self._bboxes
@bboxes.setter
def bboxes(self, bboxes: tuple | list) -> None:
if (isinstance(bboxes, tuple) and len(bboxes) == 3
and isinstance(bboxes[0], tuple)):
bboxes = [bboxes]
self._bboxes = list(bboxes)
self._features = []
for (x0, y0), w, h in self._bboxes:
dim = min(w, h)
feature = self[y0:y0 + dim, x0:x0 + dim]
feature.particle.x_p = x0 + dim / 2.
feature.particle.y_p = y0 + dim / 2.
self._features.append(feature)
def __getitem__(self, key: tuple) -> Feature:
'''Return a Feature crop with matching corner and instrument.
Parameters
----------
key : tuple[slice, slice]
``(slice_y, slice_x)`` row and column slices.
Returns
-------
feature : Feature
Cropped Feature with correct corner and shared instrument.
'''
return Feature(Hologram.__getitem__(self, key),
model=LorenzMie(instrument=self.instrument))
[docs]
def detect(self) -> int:
'''Detect and localize features in :attr:`data`.
Returns
-------
nfeatures : int
Number of features found.
'''
if self._data is None:
self._features = []
self._bboxes = []
return 0
df = self.localizer.localize(self._data)
self._features = []
self._bboxes = []
for _, row in df.iterrows():
(x0, y0), w, h = row.bbox
dim = min(w, h)
feature = self[y0:y0 + dim, x0:x0 + dim]
feature.particle.x_p = row.x_p
feature.particle.y_p = row.y_p
self._features.append(feature)
self._bboxes.append(row.bbox)
return len(self._features)
[docs]
def estimate(self) -> None:
'''Estimate parameters for all current features.'''
for feature in self.features:
feature.estimate()
[docs]
def optimize(self) -> Results:
'''Optimize parameters for all current features.
Returns
-------
results : pandas.DataFrame
Fitted values, uncertainties, and goodness-of-fit for each
feature.
'''
results = [feature.optimize() for feature in self.features]
self._results = pd.DataFrame(results)
return self._results
[docs]
def analyze(self, data: Image | None = None) -> Results:
'''Detect features, estimate parameters, and optimize fits.
Parameters
----------
data : numpy.ndarray, optional
Normalized hologram. Updates :attr:`data` if provided.
Returns
-------
results : pandas.DataFrame
Optimized parameters of the generative model for each
detected feature.
'''
self.data = data
self.detect()
self.estimate()
return self.optimize()
[docs]
@classmethod
def example(cls) -> None: # pragma: no cover
from time import perf_counter
from pylorenzmie.utilities import example_hologram
frame = cls()
frame.instrument.wavelength = 0.447
frame.instrument.magnification = 0.048
frame.instrument.n_m = 1.34
frame.data = example_hologram('image0010.png').data
n = frame.detect()
print(f'Detected {n} feature(s)')
frame.estimate()
for i, feature in enumerate(frame.features):
print(f'Feature {i}: {feature.particle}')
start = perf_counter()
results = frame.optimize()
print(f'Optimized in {perf_counter() - start:.3f} s')
print(results)
if __name__ == '__main__': # pragma: no cover
Frame.example()