class FeatureExtractor: _Data = thread_safe_bindable_collection(fields=[ 'bn_foreground_detection', 'bn_drop_profile_px', ]) def __init__(self, image: Bindable[np.ndarray], params: 'FeatureExtractorParams', *, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: self._loop = loop or asyncio.get_event_loop() self._bn_image = image self.params = params self._data = self._Data( _loop=self._loop, bn_foreground_detection=None, bn_drop_profile_px=None, ) self.is_busy = AccessorBindable(getter=self.get_is_busy) self._updater_worker = UpdaterWorker( do_update=self._update, on_idle=self.is_busy.poke, loop=self._loop, ) self.bn_foreground_detection = self._data.bn_foreground_detection # type: Bindable[Optional[np.ndarray]] self.bn_drop_profile_px = self._data.bn_drop_profile_px # type: Bindable[Optional[np.ndarray]] # Update extracted features whenever image or params change. self._bn_image.on_changed.connect(self._queue_update) self.params.bn_drop_region_px.on_changed.connect(self._queue_update) self.params.bn_thresh.on_changed.connect(self._queue_update) # First update to initialise features. self._queue_update() def _queue_update(self) -> None: was_busy = self._updater_worker.is_busy self._updater_worker.queue_update() if not was_busy: self.is_busy.poke() # This method will be run on different threads (could be called by UpdaterWorker), so make sure it stays # thread-safe. def _update(self) -> None: editor = self._data.edit(timeout=1) assert editor is not None try: new_foreground_detection = self._apply_foreground_detection() new_drop_profile_px = self._extract_drop_profile_px( new_foreground_detection) editor.set_value('bn_foreground_detection', new_foreground_detection) editor.set_value('bn_drop_profile_px', new_drop_profile_px) except Exception as exc: # If any exceptions occur, discard changes and re-raise the exception. editor.discard() raise exc else: # Otherwise commit the changes. editor.commit() def _apply_foreground_detection(self) -> Optional[np.ndarray]: image = self._bn_image.get() if image is None: return None return apply_foreground_detection( image=image, thresh=self.params.bn_thresh.get(), ) def _extract_drop_profile_px( self, binary_image: Optional[np.ndarray]) -> Optional[np.ndarray]: if binary_image is None: return None drop_region = self.params.bn_drop_region_px.get() if drop_region is None: return None drop_region = drop_region.as_type(int) drop_image = binary_image[drop_region.y0:drop_region.y1, drop_region.x0:drop_region.x1] drop_profile_px = extract_drop_profile(drop_image) drop_profile_px += drop_region.pos return drop_profile_px def get_is_busy(self) -> bool: return self._updater_worker.is_busy async def wait_until_not_busy(self) -> None: while self.is_busy.get(): await self.is_busy.on_changed.wait()
class FeatureExtractor: _Data = thread_safe_bindable_collection(fields=[ 'bn_edge_detection', 'bn_drop_profile_px', 'bn_needle_profile_px', 'bn_needle_width_px', ]) def __init__(self, image: ReadBindable[np.ndarray], params: 'FeatureExtractorParams', *, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: self._loop = loop or asyncio.get_event_loop() self._bn_image = image self.params = params self._data = self._Data( _loop=self._loop, bn_edge_detection=None, bn_drop_profile_px=None, bn_needle_profile_px=None, bn_needle_width_px=math.nan, ) self.is_busy = AccessorBindable(getter=self.get_is_busy) self._updater_worker = UpdaterWorker( do_update=self._update, on_idle=self.is_busy.poke, loop=self._loop, ) self.bn_edge_detection = self._data.bn_edge_detection # type: ReadBindable[Optional[np.ndarray]] self.bn_drop_profile_px = self._data.bn_drop_profile_px # type: ReadBindable[Optional[np.ndarray]] self.bn_needle_profile_px = self._data.bn_needle_profile_px # type: ReadBindable[Optional[Tuple[np.ndarray, np.ndarray]]] self.bn_needle_width_px = self._data.bn_needle_width_px # type: ReadBindable[float] # Update extracted features whenever image or params change. self._bn_image.on_changed.connect(self._queue_update) self.params.bn_drop_region_px.on_changed.connect(self._queue_update) self.params.bn_needle_region_px.on_changed.connect(self._queue_update) self.params.bn_canny_min.on_changed.connect(self._queue_update) self.params.bn_canny_max.on_changed.connect(self._queue_update) # First update to initialise features. self._queue_update() def _queue_update(self) -> None: was_busy = self._updater_worker.is_busy self._updater_worker.queue_update() if not was_busy: self.is_busy.poke() # This method will be run on different threads (could be called by UpdaterWorker), so make sure it stays # thread-safe. def _update(self) -> None: editor = self._data.edit(timeout=1) assert editor is not None try: new_edge_detection = self._apply_edge_detection() new_drop_profile_px = self._extract_drop_profile_px( new_edge_detection) new_needle_profile_px = self._extract_needle_profile_px( new_edge_detection) new_needle_width_px = self._extract_needle_width_px( new_needle_profile_px) editor.set_value('bn_edge_detection', new_edge_detection) editor.set_value('bn_drop_profile_px', new_drop_profile_px) editor.set_value('bn_needle_profile_px', new_needle_profile_px) editor.set_value('bn_needle_width_px', new_needle_width_px) except Exception as exc: # If any exceptions occur, discard changes and re-raise the exception. editor.discard() raise exc else: # Otherwise commit the changes. editor.commit() def _apply_edge_detection(self) -> Optional[np.ndarray]: image = self._bn_image.get() if image is None: return None return apply_edge_detection( image=image, canny_min=self.params.bn_canny_min.get(), canny_max=self.params.bn_canny_max.get(), ) def _extract_drop_profile_px( self, binary_image: Optional[np.ndarray]) -> Optional[np.ndarray]: if binary_image is None: return None drop_region = self.params.bn_drop_region_px.get() if drop_region is None: return None drop_region = drop_region.map(int) drop_image = binary_image[drop_region.y0:drop_region.y1, drop_region.x0:drop_region.x1] drop_profile_px = extract_drop_profile(drop_image) drop_profile_px += drop_region.position return drop_profile_px def _extract_needle_profile_px( self, binary_image: Optional[np.ndarray] ) -> Optional[Tuple[np.ndarray, np.ndarray]]: if binary_image is None: return None needle_region = self.params.bn_needle_region_px.get() if needle_region is None: return None needle_region = needle_region.map(int) needle_image = binary_image[needle_region.y0:needle_region.y1, needle_region.x0:needle_region.x1] needle_profile_px = extract_needle_profile(needle_image) needle_profile_px = tuple(x + needle_region.position for x in needle_profile_px) return needle_profile_px def _extract_needle_width_px( self, needle_profile: Optional[Tuple[np.ndarray, np.ndarray]]) -> float: if needle_profile is None: return math.nan return calculate_width_from_needle_profile(needle_profile) @property def is_sessile(self) -> bool: drop_region = self.params.bn_drop_region_px.get() needle_region = self.params.bn_needle_region_px.get() if needle_region is None or drop_region is None: # Can't determine if is sessile, just return False. return False if needle_region.pt0.y > drop_region.pt0.y: # Needle region is below drop region, probably sessile drop. return True def get_is_busy(self) -> bool: return self._updater_worker.is_busy async def wait_until_not_busy(self) -> None: while self.is_busy.get(): await self.is_busy.on_changed.wait()
class YoungLaplaceFitter: PROFILE_FIT_SAMPLES = 500 _Data = thread_safe_bindable_collection(fields=[ 'apex_pos', 'apex_radius', 'bond_number', 'rotation', 'profile_fit', 'residuals', 'volume', 'surface_area', ]) def __init__(self, features: FeatureExtractor, *, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: self._loop = loop or asyncio.get_event_loop() self._features = features self._is_sessile = False self._data = self._Data( _loop=self._loop, apex_pos=Vector2(math.nan, math.nan), apex_radius=math.nan, bond_number=math.nan, rotation=math.nan, profile_fit=None, residuals=None, volume=math.nan, surface_area=math.nan, ) self._stop_flag = False self.bn_is_busy = AccessorBindable(getter=self.get_is_busy) self._updater_worker = UpdaterWorker(do_update=self._update, on_idle=self.bn_is_busy.poke, loop=self._loop) self.bn_apex_pos = self._data.apex_pos # type: Bindable[Vector2[float]] self.bn_apex_radius = self._data.apex_radius # type: Bindable[float] self.bn_bond_number = self._data.bond_number # type: Bindable[float] self.bn_rotation = self._data.rotation # type: Bindable[float] self.bn_profile_fit = self._data.profile_fit # type: Bindable[np.ndarray] self.bn_residuals = self._data.residuals # type: Bindable[np.ndarray] self.bn_volume = self._data.volume # type: Bindable[float] self.bn_surface_area = self._data.surface_area # type: Bindable[float] self._log = '' self._log_lock = threading.Lock() self.bn_log = AccessorBindable(getter=self.get_log) # Reanalyse when extracted drop profile changes features.bn_drop_profile_px.on_changed.connect( self._hdl_features_changed) # First update to initialise attributes. self._queue_update() def _hdl_features_changed(self) -> None: self._queue_update() def _queue_update(self) -> None: was_busy = self._updater_worker.is_busy self._updater_worker.queue_update() if not was_busy: self.bn_is_busy.poke() # This method will be run on different threads (could be called by UpdaterWorker), so make sure it stays # thread-safe. def _update(self) -> None: if self._stop_flag: return drop_profile_px = self._features.bn_drop_profile_px.get() if drop_profile_px is None: return drop_profile_px = drop_profile_px.copy() self._is_sessile = self._features.is_sessile if not self._is_sessile: # YoungLaplaceFit takes in a drop profile where the drop is deformed in the negative y-direction. # (Remember that in 'image coordinates', positive y-direction is 'downwards') drop_profile_px[:, 1] *= -1 self._clear_log() fit = YoungLaplaceFit(drop_profile=drop_profile_px, on_update=self._ylfit_incremental_update, logger=self._append_log) # This method will be run on different threads (could be called by UpdaterWorker), so make sure it stays # thread-safe. def _ylfit_incremental_update(self, ylfit: YoungLaplaceFit) -> None: if self._stop_flag: ylfit.cancel() return editor = self._data.edit(timeout=1) assert editor is not None try: apex_pos = Vector2(ylfit.apex_x, ylfit.apex_y) rotation = ylfit.rotation profile_fit = ylfit(np.linspace(0, 1, num=self.PROFILE_FIT_SAMPLES)) if not self._is_sessile: apex_pos = Vector2(apex_pos.x, -apex_pos.y) rotation *= -1 profile_fit[:, 1] *= -1 editor.set_value('apex_pos', apex_pos) editor.set_value('apex_radius', ylfit.apex_radius) editor.set_value('bond_number', ylfit.bond_number) editor.set_value('rotation', rotation) editor.set_value('profile_fit', profile_fit) editor.set_value('residuals', ylfit.residuals) editor.set_value('volume', ylfit.volume) editor.set_value('surface_area', ylfit.surface_area) except Exception as exc: # If any exceptions occur, discard changes and re-raise the exception. editor.discard() raise exc else: # Otherwise commit the changes. editor.commit() def stop(self) -> None: self._stop_flag = True def get_is_busy(self) -> bool: return self._updater_worker.is_busy async def wait_until_not_busy(self) -> None: while self.bn_is_busy.get(): await self.bn_is_busy.on_changed.wait() def _append_log(self, message: str) -> None: if message == '': return with self._log_lock: self._log += message self._loop.call_soon_threadsafe(self.bn_log.poke) def _clear_log(self) -> None: if self._log == '': return with self._log_lock: self._log = '' self._loop.call_soon_threadsafe(self.bn_log.poke) def get_log(self) -> str: with self._log_lock: return self._log