class IFTAnalysisSaverOptions: def __init__(self) -> None: self.bn_save_dir_parent = VariableBindable( None) # type: Bindable[Optional[Path]] self.bn_save_dir_name = VariableBindable('') self.drop_residuals_figure_opts = FigureOptions(should_save=True, dpi=300, size_w=10, size_h=10) self.ift_figure_opts = FigureOptions(should_save=True, dpi=300, size_w=15, size_h=9) self.volume_figure_opts = FigureOptions(should_save=True, dpi=300, size_w=15, size_h=9) self.surface_area_figure_opts = FigureOptions(should_save=True, dpi=300, size_w=15, size_h=9) @property def save_root_dir(self) -> Path: return self.bn_save_dir_parent.get() / self.bn_save_dir_name.get()
class ConanSaveParamsFactory: def __init__(self) -> None: self.bn_save_dir_parent = VariableBindable(None) self.bn_save_dir_name = VariableBindable('') self.angle_figure_opts = FigureOptions( should_save=False, dpi=300, size_w=15, size_h=9 ) @property def save_root_dir(self) -> Path: return self.bn_save_dir_parent.get() / self.bn_save_dir_name.get() def create(self) -> ConanSaveParams: parent_dir = self.bn_save_dir_parent.get() assert parent_dir is not None name = self.bn_save_dir_name.get() assert name root_dir = parent_dir / name return ConanSaveParams( root_dir, copy.deepcopy(self.angle_figure_opts), )
class CameraAcquirer(ImageAcquirer): def __init__(self) -> None: self._loop = asyncio.get_event_loop() self.bn_camera = VariableBindable(None) # type: Bindable[Optional[Camera]] self.bn_num_frames = VariableBindable(1) self.bn_frame_interval = VariableBindable(None) # type: Bindable[Optional[float]] def acquire_images(self) -> Sequence[InputImage]: camera = self.bn_camera.get() if camera is None: raise ValueError("'camera' can't be None") num_frames = self.bn_num_frames.get() if num_frames is None or num_frames <= 0: raise ValueError( "'num_frames' must be > 0 and not None, currently: '{}'" .format(num_frames) ) frame_interval = self.bn_frame_interval.get() if frame_interval is None or frame_interval <= 0: if num_frames == 1: frame_interval = 0 else: raise ValueError( "'frame_interval' must be > 0 and not None, currently: '{}'" .format(frame_interval) ) input_images = [] for i in range(num_frames): capture_delay = i * frame_interval input_image = _BaseCameraInputImage( camera=camera, delay=capture_delay, first_image=input_images[0] if input_images else None, loop=self._loop, ) input_images.append(input_image) return input_images def get_image_size_hint(self) -> Optional[Tuple[int, int]]: camera = self.bn_camera.get() if camera is None: return return camera.get_image_size_hint()
class FigureOptions: def __init__(self, should_save: bool, dpi: int, size_w: float, size_h: float) -> None: self.bn_should_save = VariableBindable(should_save) self.bn_dpi = VariableBindable(dpi) self.bn_size_w = VariableBindable(size_w) self.bn_size_h = VariableBindable(size_h) @property def size(self) -> Tuple[float, float]: return self.bn_size_w.get(), self.bn_size_h.get()
class TestVariableBindable: def setup(self): self.initial = object() self.bindable = VariableBindable(self.initial) def test_initial_value(self): assert self.bindable.get() == self.initial def test_set_and_get(self): new_value = object() self.bindable.set(new_value) assert self.bindable.get() == new_value
class ConanAnalysisSaverOptions: def __init__(self) -> None: self.bn_save_dir_parent = VariableBindable( None) # type: Bindable[Optional[Path]] self.bn_save_dir_name = VariableBindable('') self.angle_figure_opts = FigureOptions(should_save=True, dpi=300, size_w=15, size_h=9) @property def save_root_dir(self) -> Path: return self.bn_save_dir_parent.get() / self.bn_save_dir_name.get()
class WizardController: def __init__(self, pages: Iterable) -> None: self._pages = tuple(pages) self.bn_current_page = VariableBindable(self._pages[0]) def next_page(self) -> None: current_page = self.bn_current_page.get() next_page_idx = self._pages.index(current_page) + 1 next_page = self._pages[next_page_idx] self.bn_current_page.set(next_page) def prev_page(self) -> None: current_page = self.bn_current_page.get() prev_page_idx = self._pages.index(current_page) - 1 prev_page = self._pages[prev_page_idx] self.bn_current_page.set(prev_page)
class ImageSequenceAcquirer(ImageAcquirer): IS_REPLICATED = False def __init__(self) -> None: self.bn_images = VariableBindable( tuple()) # type: Bindable[Sequence[np.ndarray]] self.bn_frame_interval = VariableBindable( None) # type: Bindable[Optional[int]] def acquire_images(self) -> Sequence[InputImage]: images = self.bn_images.get() if len(images) == 0: raise ValueError("'_images' can't be empty") frame_interval = self.bn_frame_interval.get() if frame_interval is None or frame_interval <= 0: if len(images) == 1: # Since only one image, we don't care about the frame_interval. frame_interval = 0 else: raise ValueError( "'frame_interval' must be > 0 and not None, currently: '{}'" .format(frame_interval)) input_images = [] for i, img in enumerate(images): input_image = _BaseImageSequenceInputImage(image=img, timestamp=i * frame_interval) input_image.is_replicated = self.IS_REPLICATED input_images.append(input_image) return input_images def get_image_size_hint(self) -> Optional[Tuple[int, int]]: images = self.bn_images.get() if images is None or len(images) == 0: return None first_image = images[0] return first_image.shape[1::-1]
class WizardModel: def __init__(self, pages: Iterable[Any]) -> None: self._pages = tuple(pages) self._interpage_actions = {} self.bn_current_page = VariableBindable(self._pages[0]) def next_page(self) -> None: current_page = self.bn_current_page.get() next_page_idx = self._pages.index(current_page) + 1 next_page = self._pages[next_page_idx] self.perform_interpage_action(current_page, next_page) self.bn_current_page.set(next_page) def prev_page(self) -> None: current_page = self.bn_current_page.get() prev_page_idx = self._pages.index(current_page) - 1 prev_page = self._pages[prev_page_idx] self.perform_interpage_action(current_page, prev_page) self.bn_current_page.set(prev_page) def perform_interpage_action(self, start_page: Any, end_page: Any) -> None: if (start_page, end_page) not in self._interpage_actions: return callback = self._interpage_actions[(start_page, end_page)] callback() def register_interpage_action(self, start_page: Any, end_page: Any, callback: Callable[[], Any]) -> None: self._interpage_actions[(start_page, end_page)] = callback
class IFTSession: def __init__(self, do_exit: Callable[[], Any], *, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop self._do_exit = do_exit self._feature_extractor_params = FeatureExtractorParams() self._physprops_calculator_params = PhysicalPropertiesCalculatorParams( ) self._bn_analyses = VariableBindable( tuple()) # type: Bindable[Sequence[IFTDropAnalysis]] self._analyses_saved = False self.image_acquisition = ImageAcquisitionModel() self.image_acquisition.use_acquirer_type(AcquirerType.LOCAL_STORAGE) self.physical_parameters = PhysicalParametersModel( physprops_calculator_params=self._physprops_calculator_params, ) self.image_processing = IFTImageProcessingModel( image_acquisition=self.image_acquisition, feature_extractor_params=self._feature_extractor_params, do_extract_features=self.extract_features, ) self.results = IFTResultsModel( in_analyses=self._bn_analyses, do_cancel_analyses=self.cancel_analyses, do_save_analyses=self.save_analyses, create_save_options=self._create_save_options, check_if_safe_to_discard=self.check_if_safe_to_discard_analyses, ) def start_analyses(self) -> None: assert len(self._bn_analyses.get()) == 0 new_analyses = [] input_images = self.image_acquisition.acquire_images() for input_image in input_images: new_analysis = IFTDropAnalysis( input_image=input_image, do_extract_features=self.extract_features, do_young_laplace_fit=self.young_laplace_fit, do_calculate_physprops=self.calculate_physprops) new_analyses.append(new_analysis) self._bn_analyses.set(new_analyses) self._analyses_saved = False def cancel_analyses(self) -> None: analyses = self._bn_analyses.get() for analysis in analyses: analysis.cancel() def clear_analyses(self) -> None: self.cancel_analyses() self._bn_analyses.set(tuple()) def save_analyses(self, options: IFTAnalysisSaverOptions) -> None: analyses = self._bn_analyses.get() if len(analyses) == 0: return save_drops(analyses, options) self._analyses_saved = True def _create_save_options(self) -> IFTAnalysisSaverOptions: return IFTAnalysisSaverOptions() def check_if_safe_to_discard_analyses(self) -> bool: if self._analyses_saved: return True else: analyses = self._bn_analyses.get() if len(analyses) == 0: return True all_images_replicated = all(analysis.is_image_replicated for analysis in analyses) if not all_images_replicated: return False return True def extract_features(self, image: Bindable[np.ndarray]) -> FeatureExtractor: return FeatureExtractor( image=image, params=self._feature_extractor_params, loop=self._loop, ) def young_laplace_fit( self, extracted_features: FeatureExtractor) -> YoungLaplaceFitter: return YoungLaplaceFitter(features=extracted_features, loop=self._loop) def calculate_physprops( self, extracted_features: FeatureExtractor, young_laplace_fit: YoungLaplaceFitter ) -> PhysicalPropertiesCalculator: return PhysicalPropertiesCalculator( features=extracted_features, young_laplace_fit=young_laplace_fit, params=self._physprops_calculator_params, ) def exit(self) -> None: self.clear_analyses() self.image_acquisition.destroy() self._do_exit()
class ConanSession: def __init__(self, do_exit: Callable[[], Any], *, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop self._do_exit = do_exit self._feature_extractor_params = FeatureExtractorParams() self._conancalc_params = ContactAngleCalculatorParams() self._bn_analyses = VariableBindable(tuple()) # type: Bindable[Sequence[ConanAnalysis]] self._analyses_saved = False self.image_acquisition = ImageAcquisitionModel() self.image_acquisition.use_acquirer_type(AcquirerType.LOCAL_STORAGE) self.image_processing = ConanImageProcessingModel( image_acquisition=self.image_acquisition, feature_extractor_params=self._feature_extractor_params, conancalc_params=self._conancalc_params, do_extract_features=self.extract_features, ) self.results = ConanResultsModel( in_analyses=self._bn_analyses, do_cancel_analyses=self.cancel_analyses, do_save_analyses=self.save_analyses, create_save_options=self._create_save_options, check_if_safe_to_discard=self.check_if_safe_to_discard_analyses, ) def start_analyses(self) -> None: assert len(self._bn_analyses.get()) == 0 new_analyses = [] input_images = self.image_acquisition.acquire_images() for input_image in input_images: new_analysis = ConanAnalysis( input_image=input_image, do_extract_features=self.extract_features, do_calculate_conan=self.calculate_contact_angle, ) new_analyses.append(new_analysis) self._bn_analyses.set(new_analyses) self._analyses_saved = False def cancel_analyses(self) -> None: analyses = self._bn_analyses.get() for analysis in analyses: analysis.cancel() def clear_analyses(self) -> None: self.cancel_analyses() self._bn_analyses.set(tuple()) def save_analyses(self, options: ConanAnalysisSaverOptions) -> None: analyses = self._bn_analyses.get() if len(analyses) == 0: return save_drops(analyses, options) self._analyses_saved = True def _create_save_options(self) -> ConanAnalysisSaverOptions: return ConanAnalysisSaverOptions() def check_if_safe_to_discard_analyses(self) -> bool: if self._analyses_saved: return True else: analyses = self._bn_analyses.get() if len(analyses) == 0: return True all_images_replicated = all( analysis.is_image_replicated for analysis in analyses ) if not all_images_replicated: return False return True def extract_features(self, image: Bindable[np.ndarray]) -> FeatureExtractor: return FeatureExtractor( image=image, params=self._feature_extractor_params, loop=self._loop, ) def calculate_contact_angle(self, extracted_features: FeatureExtractor) -> ContactAngleCalculator: return ContactAngleCalculator( features=extracted_features, params=self._conancalc_params, ) def exit(self) -> None: self.clear_analyses() self.image_acquisition.destroy() self._do_exit()
class PendantAnalysisJob: class Status(Enum): WAITING_FOR_IMAGE = ('Waiting for image', False) EXTRACTING_FEATURES = ('Extracting features', False) FITTING = ('Fitting', False) FINISHED = ('Finished', True) CANCELLED = ('Cancelled', True) def __init__(self, display_name: str, is_terminal: bool) -> None: self.display_name = display_name self.is_terminal = is_terminal def __str__(self) -> str: return self.display_name @inject def __init__( self, input_image: InputImage, *, physical_params_factory: PendantPhysicalParamsFactory, features_params_factory: PendantFeaturesParamsFactory, features_service: PendantFeaturesService, ylfit_service: YoungLaplaceFitService, ) -> None: self._loop = asyncio.get_event_loop() self._features_params_factory = features_params_factory self._physical_params_factory = physical_params_factory self._features_service = features_service self._ylfit_service = ylfit_service self._time_start = time.time() self._time_end = math.nan self._input_image = input_image self._status = self.Status.WAITING_FOR_IMAGE self.bn_status = AccessorBindable( getter=self._get_status, setter=self._set_status, ) self._image = None # type: Optional[np.ndarray] # The time (in Unix time) that the image was captured. self._image_timestamp = math.nan # type: float self.bn_image = AccessorBindable(self._get_image) self.bn_image_timestamp = AccessorBindable(self._get_image_timestamp) # Attributes from YoungLaplaceFitter self.bn_bond_number = VariableBindable(math.nan) self.bn_apex_coords_px = VariableBindable(Vector2(math.nan, math.nan)) self.bn_apex_radius_px = VariableBindable(math.nan) self.bn_rotation = VariableBindable(math.nan) self.bn_drop_profile_fit = VariableBindable(None) self.bn_residuals = VariableBindable(None) self.bn_arclengths = VariableBindable(None) # Attributes from PhysicalPropertiesCalculator self.bn_interfacial_tension = VariableBindable(math.nan) self.bn_volume = VariableBindable(math.nan) self.bn_surface_area = VariableBindable(math.nan) self.bn_apex_radius = VariableBindable(math.nan) self.bn_worthington = VariableBindable(math.nan) # Attributes from FeatureExtractor self.bn_drop_region = VariableBindable(None) self.bn_needle_region = VariableBindable(None) self.bn_canny_min = VariableBindable(None) self.bn_canny_max = VariableBindable(None) self.bn_drop_profile_extract = VariableBindable(None) self.bn_needle_width_px = VariableBindable(math.nan) self.bn_is_done = AccessorBindable(getter=self._get_is_done) self.bn_is_cancelled = AccessorBindable(getter=self._get_is_cancelled) self.bn_progress = AccessorBindable(self._get_progress) self.bn_time_start = AccessorBindable(self._get_time_start) self.bn_time_est_complete = AccessorBindable(self._get_time_est_complete) self.bn_status.on_changed.connect(self.bn_is_done.poke) self.bn_status.on_changed.connect(self.bn_progress.poke) self._loop.create_task(self._input_image.read()).add_done_callback(self._input_image_read_done) self._features = None self._ylfit = None def _input_image_read_done(self, read_task: Future) -> None: if read_task.cancelled(): self.cancel() return if self.bn_is_done.get(): return image, image_timestamp = read_task.result() self._image_ready(image, image_timestamp) def _image_ready(self, image: np.ndarray, image_timestamp: float) -> None: assert self._image is None self._image = image self._image_timestamp = image_timestamp # Set given image to be readonly to prevent introducing some accidental bugs. self._image.flags.writeable = False features_params = self._features_params_factory.create() self.bn_drop_region.set(features_params.drop_region) self.bn_needle_region.set(features_params.needle_region) self.bn_canny_max.set(features_params.thresh2) self.bn_canny_min.set(features_params.thresh1) self._features = self._features_service.extract(image, features_params) self._features.add_done_callback(self._features_done) self.bn_image.poke() self.bn_image_timestamp.poke() self.bn_status.set(self.Status.EXTRACTING_FEATURES) def _features_done(self, fut: asyncio.Future) -> None: features: PendantFeatures if fut.cancelled(): self.cancel() return try: features = fut.result() except Exception as e: raise e self.bn_drop_profile_extract.set(features.drop_points.T) self.bn_needle_width_px.set(features.needle_diameter) self._ylfit = self._ylfit_service.fit(features.drop_points) self._ylfit.add_done_callback(self._ylfit_done) self.bn_status.set(self.Status.FITTING) def _ylfit_done(self, fut: asyncio.Future) -> None: result: YoungLaplaceFitResult if fut.cancelled(): self.cancel() return try: result = fut.result() except Exception as e: raise e physical_params = self._physical_params_factory.create() drop_density = physical_params.drop_density continuous_density = physical_params.continuous_density needle_diameter = physical_params.needle_diameter gravity = physical_params.gravity bond = result.bond apex_x = result.apex_x apex_y = result.apex_y rotation = result.rotation residuals = result.residuals closest = result.closest arclengths = result.arclengths radius_px = result.radius surface_area_px = result.surface_area volume_px = result.volume # Keep rotation angle between -90 to 90 degrees. rotation = (rotation + np.pi/2) % np.pi - np.pi/2 needle_diameter_px = self.bn_needle_width_px.get() if needle_diameter_px is not None: px_size = needle_diameter/needle_diameter_px delta_density = abs(drop_density - continuous_density) radius = radius_px * px_size surface_area = surface_area_px * px_size**2 volume = volume_px * px_size**3 ift = delta_density * gravity * radius**2 / bond worthington = (delta_density * gravity * volume) / (PI * ift * needle_diameter) else: radius = math.nan surface_area = math.nan volume = math.nan ift = math.nan worthington = math.nan self.bn_bond_number.set(bond) self.bn_apex_coords_px.set(Vector2(apex_x, apex_y)) self.bn_apex_radius_px.set(radius_px) self.bn_rotation.set(rotation) self.bn_residuals.set(residuals) self.bn_arclengths.set(arclengths) self.bn_drop_profile_fit.set(closest.T[np.argsort(arclengths)]) self.bn_apex_radius.set(radius) self.bn_surface_area.set(surface_area) self.bn_volume.set(volume) self.bn_interfacial_tension.set(ift) self.bn_worthington.set(worthington) self.bn_status.set(self.Status.FINISHED) def cancel(self) -> None: if self.bn_status.get().is_terminal: # This is already at the end of its life. return if self.bn_status.get() is self.Status.WAITING_FOR_IMAGE: self._input_image.cancel() if self._features is not None: self._features.cancel() if self._ylfit is not None: self._ylfit.cancel() self.bn_status.set(self.Status.CANCELLED) def _get_status(self) -> Status: return self._status def _set_status(self, new_status: Status) -> None: self._status = new_status self.bn_is_cancelled.poke() if new_status.is_terminal: self._time_end = time.time() def _get_image(self) -> Optional[np.ndarray]: return self._image def _get_image_timestamp(self) -> float: return self._image_timestamp def _get_is_done(self) -> bool: return self.bn_status.get().is_terminal def _get_is_cancelled(self) -> bool: return self.bn_status.get() is self.Status.CANCELLED def _get_progress(self) -> float: if self.bn_is_done.get(): return 1 else: return 0 def _get_time_start(self) -> float: return self._time_start def _get_time_est_complete(self) -> float: if self._input_image is None: return math.nan return self._input_image.est_ready def calculate_time_elapsed(self) -> float: time_start = self._time_start if math.isfinite(self._time_end): time_elapsed = self._time_end - time_start else: time_now = time.time() time_elapsed = time_now - time_start return time_elapsed def calculate_time_remaining(self) -> float: if self.bn_is_done.get(): return 0 time_est_complete = self.bn_time_est_complete.get() time_now = time.time() time_remaining = time_est_complete - time_now return time_remaining @property def is_image_replicated(self) -> bool: return self._input_image.is_replicated