class LocalStorageAcquirer(ImageSequenceAcquirer): IS_REPLICATED = True def __init__(self) -> None: super().__init__() self.bn_last_loaded_paths = VariableBindable( tuple()) # type: VariableBindable[Sequence[Path]] def load_image_paths(self, image_paths: Sequence[Union[Path, str]]) -> None: # Sort image paths in lexicographic order, and ignore paths to directories. image_paths = sorted( [p for p in map(Path, image_paths) if not p.is_dir()]) images: MutableSequence[np.ndarray] = [] for image_path in image_paths: # Load in grayscale to save memory. image = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE) if image is None: raise ValueError(f"Failed to load image from '{image_path}'") image.flags.writeable = False images.append(image) self.bn_images.set(images) self.bn_last_loaded_paths.set(tuple(image_paths))
class LocalStorageAcquirer(ImageSequenceAcquirer): IS_REPLICATED = True def __init__(self) -> None: super().__init__() self.bn_last_loaded_paths = VariableBindable(tuple()) # type: VariableBindable[Sequence[Path]] def load_image_paths(self, image_paths: Sequence[Union[Path, str]]) -> None: # Sort image paths in lexicographic order, and ignore paths to directories. image_paths = sorted([p for p in map(Path, image_paths) if not p.is_dir()]) images = [] # type: MutableSequence[np.ndarray] for image_path in image_paths: image = cv2.imread(str(image_path)) if image is None: raise ValueError( "Failed to load image from path '{}'" .format(image_path) ) # OpenCV loads images in BGR mode, but the rest of the app works with images in RGB, so convert the read # image appropriately. image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) images.append(image) self.bn_images.set(images) self.bn_last_loaded_paths.set(tuple(image_paths))
class USBCamera(Camera): _PRECAPTURE = 5 _CAPTURE_TIMEOUT = 0.5 def __init__(self, camera_index: int) -> None: self._vc = cv2.VideoCapture(camera_index) self.bn_alive = VariableBindable(True) if not self.check_vc_works(timeout=5): raise ValueError('Camera failed to open.') # For some reason, on some cameras, the first few images captured will be dark. Consume those images now so the # camera will be "fully operational" after initialisation. for i in range(self._PRECAPTURE): self._vc.read() def check_vc_works(self, timeout: float) -> bool: start_time = time.time() while self._vc.isOpened() and (time.time() - start_time) < timeout: success = self._vc.grab() if success: # Camera still works return True else: return False def capture(self) -> np.ndarray: start_time = time.time() while self._vc.isOpened() and (time.time() - start_time) < self._CAPTURE_TIMEOUT: success, image = self._vc.read() if success: return cv2.cvtColor(image, cv2.COLOR_BGR2RGB) self.release() raise CameraCaptureError def get_image_size_hint(self) -> Optional[Tuple[int, int]]: width = self._vc.get(cv2.CAP_PROP_FRAME_WIDTH) height = self._vc.get(cv2.CAP_PROP_FRAME_HEIGHT) return width, height def release_if_not_working(self, timeout=_CAPTURE_TIMEOUT) -> None: if not self.check_vc_works(timeout): self.release() def release(self) -> None: self._vc.release() self.bn_alive.set(False)
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 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 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 AppRootModel: def __init__(self, *, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop self.bn_mode = VariableBindable(AppMode.MAIN_MENU) self.main_menu = MainMenuModel( do_launch_ift=( lambda: self.bn_mode.set(AppMode.IFT) ), do_launch_conan=( lambda: self.bn_mode.set(AppMode.CONAN) ), do_exit=( lambda: self.bn_mode.set(AppMode.QUIT) ), ) def new_ift_session(self) -> IFTSession: session = IFTSession( do_exit=( lambda: self.bn_mode.set(AppMode.MAIN_MENU) ), loop=self._loop, ) return session def new_conan_session(self) -> ConanSession: session = ConanSession( do_exit=( lambda: self.bn_mode.set(AppMode.MAIN_MENU) ), loop=self._loop, ) return session
class PhysicalPropertiesCalculator: def __init__( self, features: FeatureExtractor, young_laplace_fit: YoungLaplaceFitter, params: PhysicalPropertiesCalculatorParams, ) -> None: self._extracted_features = features self._young_laplace_fit = young_laplace_fit self.params = params 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) features.bn_needle_width_px.on_changed.connect(self._recalculate) # Assume that bond number changes at the same time as other attributes young_laplace_fit.bn_bond_number.on_changed.connect(self._recalculate) params.bn_inner_density.on_changed.connect(self._recalculate) params.bn_outer_density.on_changed.connect(self._recalculate) params.bn_needle_width.on_changed.connect(self._recalculate) params.bn_gravity.on_changed.connect(self._recalculate) self._recalculate() def _recalculate(self) -> None: m_per_px = self._get_m_per_px() inner_density = self.params.bn_inner_density.get() outer_density = self.params.bn_outer_density.get() needle_width_m = self.params.bn_needle_width.get() gravity = self.params.bn_gravity.get() if inner_density is None or outer_density is None or needle_width_m is None or gravity is None: return bond_number = self._young_laplace_fit.bn_bond_number.get() apex_radius_px = self._young_laplace_fit.bn_apex_radius.get() apex_radius_m = m_per_px * apex_radius_px interfacial_tension = calculate_ift( inner_density=inner_density, outer_density=outer_density, bond_number=bond_number, apex_radius=apex_radius_m, gravity=gravity ) volume_px3 = self._young_laplace_fit.bn_volume.get() volume_m3 = m_per_px**3 * volume_px3 surface_area_px2 = self._young_laplace_fit.bn_surface_area.get() surface_area_m2 = m_per_px**2 * surface_area_px2 worthington = calculate_worthington( inner_density=inner_density, outer_density=outer_density, gravity=gravity, ift=interfacial_tension, volume=volume_m3, needle_width=needle_width_m, ) self.bn_interfacial_tension.set(interfacial_tension) self.bn_volume.set(volume_m3) self.bn_surface_area.set(surface_area_m2) self.bn_apex_radius.set(apex_radius_m) self.bn_worthington.set(worthington) def _get_m_per_px(self) -> float: needle_width_px = self._extracted_features.bn_needle_width_px.get() needle_width_m = self.params.bn_needle_width.get() if needle_width_px is None or needle_width_m is None: return math.nan return needle_width_m/needle_width_px
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 GenicamCamera(Camera): def __init__(self, hacquirer: harvesters.ImageAcquirer) -> None: self._hacquirer = hacquirer self.bn_alive = VariableBindable(False) try: hacquirer.start_acquisition(run_in_background=True) except genicam.gentl.IoException as e: raise ValueError('Camera failed to open.') from e self.bn_alive.set(True) def capture(self) -> np.ndarray: with self._hacquirer.fetch_buffer() as buf: if not buf.payload.components: raise CameraCaptureError component = buf.payload.components[0] width, height, channels = component.width, component.height, int( component.num_components_per_pixel) x_padding = component.x_padding data = component.data data = np.lib.stride_tricks.as_strided( data, shape=(height, width * channels), strides=((width * channels + x_padding) * data.itemsize, data.itemsize), writeable=False, ) data_format = component.data_format if data_format == 'Mono8': image = cv2.cvtColor(data, cv2.COLOR_GRAY2RGB) elif data_format == 'Mono10': image = cv2.cvtColor(data, cv2.COLOR_GRAY2RGB) image = (image / 1023 * 255).astype(np.uint8) elif data_format == 'Mono12': image = cv2.cvtColor(data, cv2.COLOR_GRAY2RGB) image = (image / 4095 * 255).astype(np.uint8) elif data_format == 'RGB8': image = data.reshape(height, width, 3).copy() elif data_format == 'RGB10': image = data.reshape(height, width, 3) image = (image / 1023 * 255).astype(np.uint8) elif data_format == 'RGB12': image = data.reshape(height, width, 3) image = (image / 4095 * 255).astype(np.uint8) elif data_format == 'BGR8': image = cv2.cvtColor( data.reshape(height, width, 3), code=cv2.COLOR_BGR2RGB, ) elif data_format == 'BGR10': image = cv2.cvtColor( data.reshape(height, width, 3), code=cv2.COLOR_BGR2RGB, ) image = (image / 1023 * 255).astype(np.uint8) elif data_format == 'BGR12': image = cv2.cvtColor( data.reshape(height, width, 3), code=cv2.COLOR_BGR2RGB, ) image = (image / 4095 * 255).astype(np.uint8) elif data_format in { 'BayerGR8', 'BayerRG8', 'BayerBG8', 'BayerGB8' }: image = cv2.cvtColor( data, # OpenCV has a different Bayer pattern naming convention. code={ 'BayerGR8': cv2.COLOR_BayerGB2RGB, 'BayerRG8': cv2.COLOR_BayerBG2RGB, 'BayerBG8': cv2.COLOR_BayerRG2RGB, 'BayerGB8': cv2.COLOR_BayerGR2RGB, }[data_format]) elif data_format in { 'BayerGR10', 'BayerRG10', 'BayerBG10', 'BayerGB10' }: image = cv2.cvtColor( data, # OpenCV has a different Bayer pattern naming convention. code={ 'BayerGR10': cv2.COLOR_BayerGB2RGB, 'BayerRG10': cv2.COLOR_BayerBG2RGB, 'BayerBG10': cv2.COLOR_BayerRG2RGB, 'BayerGB10': cv2.COLOR_BayerGR2RGB, }[data_format]) image = (image / 1023 * 255).astype(np.uint8) elif data_format in { 'BayerGR12', 'BayerRG12', 'BayerBG12', 'BayerGB12' }: image = cv2.cvtColor( data, # OpenCV has a different Bayer pattern naming convention. code={ 'BayerGR12': cv2.COLOR_BayerGB2RGB, 'BayerRG12': cv2.COLOR_BayerBG2RGB, 'BayerBG12': cv2.COLOR_BayerRG2RGB, 'BayerGB12': cv2.COLOR_BayerGR2RGB, }[data_format]) image = (image / 4095 * 255).astype(np.uint8) else: raise CameraCaptureError( 'Unsupported pixel format {}'.format(data_format)) return image def get_image_size_hint(self) -> Optional[Tuple[int, int]]: if not hasattr(self, '_hacquirer'): return width = self._hacquirer.remote_device.node_map.Width.value height = self._hacquirer.remote_device.node_map.Height.value return width, height def destroy(self) -> None: if not hasattr(self, '_hacquirer'): return self._hacquirer.stop_acquisition() self._hacquirer.destroy() del self._hacquirer self.bn_alive.set(False)
class IFTPreviewPluginModel: def __init__( self, *, image_acquisition: ImageAcquisitionService, features_params_factory: PendantFeaturesParamsFactory, features_service: PendantFeaturesService, ) -> None: self._image_acquisition = image_acquisition self._features_params_factory = features_params_factory self._features_service = features_service self._watchers = 0 self._acquirer_controller = None # type: Optional[AcquirerController] self.bn_acquirer_controller = AccessorBindable( getter=lambda: self._acquirer_controller ) self.bn_source_image = VariableBindable(None) # type: Bindable[Optional[np.ndarray]] self.bn_labels = VariableBindable(None) # type: Bindable[Optional[np.ndarray]] self.bn_drop_points = VariableBindable(None) # type: Bindable[Optional[np.ndarray]] self.bn_needle_rect = VariableBindable(None) self._image_acquisition.bn_acquirer.on_changed.connect( self._update_acquirer_controller, ) def watch(self) -> None: self._watchers += 1 self._update_acquirer_controller() def unwatch(self) -> None: self._watchers -= 1 self._update_acquirer_controller() def _update_acquirer_controller(self) -> None: self._destroy_acquirer_controller() if self._watchers <= 0: return new_acquirer = self._image_acquisition.bn_acquirer.get() if isinstance(new_acquirer, ImageSequenceAcquirer): new_acquirer_controller = IFTImageSequenceAcquirerController( acquirer=new_acquirer, features_params_factory=self._features_params_factory, features_service=self._features_service, out_image=self.bn_source_image, show_features=self._show_features, ) elif isinstance(new_acquirer, CameraAcquirer): new_acquirer_controller = IFTCameraAcquirerController( acquirer=new_acquirer, features_params_factory=self._features_params_factory, features_service=self._features_service, out_image=self.bn_source_image, show_features=self._show_features, ) elif new_acquirer is None: new_acquirer_controller = None else: raise ValueError( "Unknown acquirer '{}'" .format(new_acquirer) ) self._acquirer_controller = new_acquirer_controller self.bn_acquirer_controller.poke() def _show_features(self, features: Optional[PendantFeatures]) -> None: if features is None: self.bn_labels.set(None) self.bn_drop_points.set(None) self.bn_needle_rect.set(None) return self.bn_labels.set(features.labels) self.bn_drop_points.set(features.drop_points) self.bn_needle_rect.set(features.needle_rect) def _destroy_acquirer_controller(self) -> None: acquirer_controller = self._acquirer_controller if acquirer_controller is None: return acquirer_controller.destroy() self._acquirer_controller = None
class DetailPresenter(Presenter['DetailView']): def _do_init(self, in_analysis: Bindable[Optional[IFTDropAnalysis]]) -> None: self._bn_analysis = in_analysis self._analysis_unbind_tasks = [] self.bn_interfacial_tension = VariableBindable(math.nan) self.bn_volume = VariableBindable(math.nan) self.bn_surface_area = VariableBindable(math.nan) self.bn_worthington = VariableBindable(math.nan) self.bn_bond_number = VariableBindable(math.nan) self.bn_apex_coords = VariableBindable((math.nan, math.nan)) self.bn_image_angle = VariableBindable(math.nan) self.bn_drop_image = VariableBindable(None) self.bn_drop_profile_extract = VariableBindable(None) self.bn_drop_profile_fit = VariableBindable(None) self.bn_residuals = VariableBindable(None) self.bn_log_text = VariableBindable('') self.__event_connections = [] def view_ready(self) -> None: self.__event_connections.extend( [self._bn_analysis.on_changed.connect(self._hdl_analysis_changed)]) self._hdl_analysis_changed() def _hdl_analysis_changed(self) -> None: self._unbind_analysis() analysis = self._bn_analysis.get() if analysis is None: self.view.show_waiting_placeholder() return self.view.hide_waiting_placeholder() self._bind_analysis(analysis) def _bind_analysis(self, analysis: IFTDropAnalysis) -> None: assert len(self._analysis_unbind_tasks) == 0 data_bindings = [ analysis.bn_interfacial_tension.bind_to( self.bn_interfacial_tension), analysis.bn_volume.bind_to(self.bn_volume), analysis.bn_surface_area.bind_to(self.bn_surface_area), analysis.bn_worthington.bind_to(self.bn_worthington), analysis.bn_bond_number.bind_to(self.bn_bond_number), analysis.bn_apex_coords_px.bind_to(self.bn_apex_coords), analysis.bn_rotation.bind_to(self.bn_image_angle), analysis.bn_residuals.bind_to(self.bn_residuals), analysis.bn_log.bind_to(self.bn_log_text), ] self._analysis_unbind_tasks.extend(db.unbind for db in data_bindings) event_connections = [ analysis.bn_image.on_changed.connect( self._hdl_analysis_image_changed), analysis.bn_drop_profile_extract.on_changed.connect( self._hdl_analysis_drop_profile_extract_changed), analysis.bn_drop_profile_fit.on_changed.connect( self._hdl_analysis_drop_profile_fit_changed), ] self._analysis_unbind_tasks.extend(ec.disconnect for ec in event_connections) self._hdl_analysis_image_changed() self._hdl_analysis_drop_profile_extract_changed() self._hdl_analysis_drop_profile_fit_changed() def _hdl_analysis_image_changed(self) -> None: analysis = self._bn_analysis.get() image = analysis.bn_image.get() if image is None: self.view.show_waiting_placeholder() return self.view.hide_waiting_placeholder() drop_region = analysis.bn_drop_region.get() assert drop_region is not None drop_image = image[drop_region.y0:drop_region.y1, drop_region.x0:drop_region.x1] self.bn_drop_image.set(drop_image) def _hdl_analysis_drop_profile_extract_changed(self) -> None: analysis = self._bn_analysis.get() drop_profile_ext = analysis.bn_drop_profile_extract.get() if drop_profile_ext is None: self.bn_drop_profile_extract.set(None) return drop_region = analysis.bn_drop_region.get() assert drop_region is not None drop_profile_ext_rel_drop_image = drop_profile_ext - drop_region.position self.bn_drop_profile_extract.set(drop_profile_ext_rel_drop_image) def _hdl_analysis_drop_profile_fit_changed(self) -> None: analysis = self._bn_analysis.get() drop_profile_fit = analysis.bn_drop_profile_fit.get() if drop_profile_fit is None: self.bn_drop_profile_fit.set(None) return drop_region = analysis.bn_drop_region.get() assert drop_region is not None drop_profile_fit_rel_drop_image = drop_profile_fit - drop_region.position self.bn_drop_profile_fit.set(drop_profile_fit_rel_drop_image) def _unbind_analysis(self) -> None: for task in self._analysis_unbind_tasks: task() self._analysis_unbind_tasks.clear() def _do_destroy(self) -> None: for ec in self.__event_connections: ec.disconnect() self._unbind_analysis()
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 ConanResultsPresenter(Presenter['ConanResultsView']): UPDATE_TIME_INTERVAL = 1 def _do_init(self, model: ConanResultsModel, page_controls: WizardPageControls) -> None: self._loop = asyncio.get_event_loop() self._model = model self._page_controls = page_controls self.individual_model = model.individual self.graphs_model = model.graphs self.bn_footer_status = VariableBindable( ResultsFooterStatus.IN_PROGRESS) self.bn_completion_progress = model.bn_analyses_completion_progress self.bn_time_elapsed = VariableBindable(math.nan) self.bn_time_remaining = VariableBindable(math.nan) self._active_save_options = None self.__event_connections = [ model.bn_analyses_time_start.on_changed.connect( self._update_times), model.bn_analyses_time_est_complete.on_changed.connect( self._update_times), ] self._update_times() def view_ready(self) -> None: self.__event_connections.extend([ self._model.bn_fitting_status.on_changed.connect( self._hdl_model_fitting_status_changed), self._model.bn_analyses.on_changed.connect( self._hdl_model_analyses_changed) ]) self._hdl_model_fitting_status_changed() self._hdl_model_analyses_changed() def _hdl_model_analyses_changed(self) -> None: analyses = self._model.bn_analyses.get() if len(analyses) == 1: self.view.hide_graphs() else: self.view.show_graphs() def _hdl_model_fitting_status_changed(self) -> None: fitting_status = self._model.bn_fitting_status.get() if fitting_status is ConanResultsModel.Status.FITTING: footer_status = ResultsFooterStatus.IN_PROGRESS elif fitting_status is ConanResultsModel.Status.FINISHED: footer_status = ResultsFooterStatus.FINISHED self.view.hide_confirm_cancel_dialog() elif fitting_status is ConanResultsModel.Status.CANCELLED: footer_status = ResultsFooterStatus.CANCELLED self.view.hide_confirm_cancel_dialog() else: footer_status = ResultsFooterStatus.IN_PROGRESS self.bn_footer_status.set(footer_status) _update_times_handle = None def _update_times(self) -> None: if self._update_times_handle is not None: self._update_times_handle.cancel() self._update_times_handle = None time_elapsed = self._model.calculate_time_elapsed() self.bn_time_elapsed.set(time_elapsed) time_remaining = self._model.calculate_time_remaining() self.bn_time_remaining.set(time_remaining) fitting_status = self._model.bn_fitting_status.get() if fitting_status is ConanResultsModel.Status.FITTING: self._update_times_handle = self._loop.call_later( delay=self.UPDATE_TIME_INTERVAL, callback=self._update_times, ) def back(self) -> None: if self._model.is_safe_to_discard: self._back() else: self.view.show_confirm_discard_dialog() def hdl_confirm_discard_response(self, accept: bool) -> None: if accept: self._back() self.view.hide_confirm_discard_dialog() def _back(self) -> None: self._page_controls.prev_page() def cancel(self) -> None: if self._model.is_safe_to_discard: self._cancel() else: self.view.show_confirm_cancel_dialog() def hdl_confirm_cancel_response(self, accept: bool) -> None: if accept: self._cancel() self.view.hide_confirm_cancel_dialog() def _cancel(self): self._model.cancel_analyses() def save(self) -> None: if self._active_save_options is not None: return self._active_save_options = self._model.create_save_options() self.view.show_save_dialog(self._active_save_options) def hdl_save_dialog_response(self, should_save: bool) -> None: if self._active_save_options is None: return self.view.hide_save_dialog() save_options = self._active_save_options self._active_save_options = None if should_save: self._model.save_analyses(save_options) def _do_destroy(self) -> None: for ec in self.__event_connections: ec.disconnect() if self._update_times_handle is not None: self._update_times_handle.cancel()
class ContactAngleCalculator: def __init__(self, features: FeatureExtractor, params: ContactAngleCalculatorParams) -> None: self._features = features self.params = params self.bn_left_tangent = VariableBindable(np.poly1d((math.nan, math.nan))) self.bn_left_angle = VariableBindable(math.nan) self.bn_left_point = VariableBindable(Vector2(math.nan, math.nan)) self.bn_right_tangent = VariableBindable(np.poly1d((math.nan, math.nan))) self.bn_right_angle = VariableBindable(math.nan) self.bn_right_point = VariableBindable(Vector2(math.nan, math.nan)) # Recalculate when inputs change features.bn_drop_profile_px.on_changed.connect(self._recalculate) params.bn_surface_line_px.on_changed.connect(self._recalculate) self._recalculate() def _recalculate(self) -> None: features = self._features params = self.params drop_profile = features.bn_drop_profile_px.get() if drop_profile is None: return surface = params.bn_surface_line_px.get() if surface is None: return drop_profile = drop_profile.copy() surface_poly1d = np.poly1d((surface.gradient, surface.eval(x=0).y)) # ContactAngle expects the coordinates of drop profile to be such that the surface has a lower y-coordinate than # the drop, so mirror the drop in y-direction. (Remember drop profile is in 'image coordinates', where # increasing y-coordinate is 'downwards') drop_profile[:, 1] *= -1 surface_poly1d = -surface_poly1d conancalc = ContactAngle(drop_profile, surface_poly1d) # Mirror the tangents and contact points back to original coordinate system as well. left_tangent = -conancalc.left_tangent left_point = Vector2(x=conancalc.left_point.x, y=-conancalc.left_point.y) right_tangent = -conancalc.right_tangent right_point = Vector2(x=conancalc.right_point.x, y=-conancalc.right_point.y) self.bn_left_tangent.set(left_tangent) self.bn_left_angle.set(conancalc.left_angle) self.bn_left_point.set(left_point) self.bn_right_tangent.set(right_tangent) self.bn_right_angle.set(conancalc.right_angle) self.bn_right_point.set(right_point)
class GraphsModel: def __init__(self, in_analyses: Bindable[Sequence[ConanAnalysis]]) -> None: self._bn_analyses = in_analyses self._tracked_analyses = [] self._tracked_analysis_unbind_tasks = {} self.bn_left_angle_data = VariableBindable((tuple(), tuple())) self.bn_right_angle_data = VariableBindable((tuple(), tuple())) self._bn_analyses.on_changed.connect( self._hdl_analyses_changed ) self._hdl_analyses_changed() def _hdl_analyses_changed(self) -> None: analyses = self._bn_analyses.get() tracked_analyses = self._tracked_analyses to_track = set(analyses) - set(tracked_analyses) for analysis in to_track: self._track_analysis(analysis) to_untrack = set(tracked_analyses) - set(analyses) for analysis in to_untrack: self._untrack_analysis(analysis) def _track_analysis(self, analysis: ConanAnalysis) -> None: unbind_tasks = [] event_connections = [ analysis.bn_left_angle.on_changed.connect( self._hdl_tracked_analysis_data_changed ), analysis.bn_right_angle.on_changed.connect( self._hdl_tracked_analysis_data_changed ), ] unbind_tasks.extend( ec.disconnect for ec in event_connections ) self._tracked_analyses.append(analysis) self._tracked_analysis_unbind_tasks[analysis] = unbind_tasks self._hdl_tracked_analysis_data_changed() def _untrack_analysis(self, analysis: ConanAnalysis) -> None: unbind_tasks = self._tracked_analysis_unbind_tasks[analysis] for task in unbind_tasks: task() del self._tracked_analysis_unbind_tasks[analysis] self._tracked_analyses.remove(analysis) def _hdl_tracked_analysis_data_changed(self) -> None: left_angle_data = [] right_angle_data = [] analyses = self._bn_analyses.get() for analysis in analyses: timestamp = analysis.bn_image_timestamp.get() if timestamp is None or not math.isfinite(timestamp): continue left_angle_value = analysis.bn_left_angle.get() right_angle_value = analysis.bn_right_angle.get() if left_angle_value is not None and math.isfinite(left_angle_value): left_angle_data.append((timestamp, left_angle_value)) if right_angle_value is not None and math.isfinite(right_angle_value): right_angle_data.append((timestamp, right_angle_value)) # Sort in ascending order of timestamp left_angle_data = sorted(left_angle_data, key=lambda x: x[0]) right_angle_data = sorted(right_angle_data, key=lambda x: x[0]) left_angle_data = tuple(zip(*left_angle_data)) right_angle_data = tuple(zip(*right_angle_data)) if len(left_angle_data) == 0: left_angle_data = (tuple(), tuple()) if len(right_angle_data) == 0: right_angle_data = (tuple(), tuple()) self.bn_left_angle_data.set(left_angle_data) self.bn_right_angle_data.set(right_angle_data)
class ResultsFooterPresenter(Presenter['ResultsFooterView']): def _do_init( self, in_status: Bindable[ResultsFooterStatus], in_progress: Bindable[float], in_time_elapsed: Bindable[float], in_time_remaining: Bindable[float], do_back: Callable[[], Any], do_cancel: Callable[[], Any], do_save: Callable[[], Any], ) -> None: self._bn_status = in_status self._bn_time_remaining = in_time_remaining self._do_back = do_back self._do_cancel = do_cancel self._do_save = do_save self.bn_progress = in_progress self.bn_progress_label = VariableBindable('') self.bn_time_elapsed = in_time_elapsed self.bn_time_remaining = VariableBindable(math.nan) self.__event_connections = [] def view_ready(self) -> None: self.__event_connections.extend([ self._bn_status.on_changed.connect(self._hdl_status_changed), self._bn_time_remaining.on_changed.connect( self._hdl_time_remaining_changed), ]) self._hdl_status_changed() self._hdl_time_remaining_changed() def _hdl_status_changed(self) -> None: status = self._bn_status.get() if status is ResultsFooterStatus.IN_PROGRESS: self.bn_progress_label.set('') self.view.show_cancel_btn() self.view.disable_save_btn() elif status is ResultsFooterStatus.FINISHED: self.bn_progress_label.set('Finished') self.view.show_back_btn() self.view.enable_save_btn() elif status is ResultsFooterStatus.CANCELLED: self.bn_progress_label.set('Cancelled') self.view.show_back_btn() self.view.enable_save_btn() def _hdl_time_remaining_changed(self) -> None: time_remaining = self._bn_time_remaining.get() self.bn_time_remaining.set(time_remaining) def back(self) -> None: self._do_back() def cancel(self) -> None: self._do_cancel() def save(self) -> None: self._do_save() def _do_destroy(self) -> None: for ec in self.__event_connections: ec.disconnect()
class ConanPreviewPluginModel: def __init__( self, *, image_acquisition: ImageAcquisitionService, params_factory: ConanParamsFactory, features_service: ConanFeaturesService, ) -> None: self._image_acquisition = image_acquisition self._params_factory = params_factory self._features_service = features_service self._watchers = 0 self._acquirer_controller = None # type: Optional[AcquirerController] self.bn_acquirer_controller = AccessorBindable( getter=lambda: self._acquirer_controller) self.bn_source_image = VariableBindable(None) self.bn_features = VariableBindable(None) self._image_acquisition.bn_acquirer.on_changed.connect( self._update_acquirer_controller, ) def watch(self) -> None: self._watchers += 1 self._update_acquirer_controller() def unwatch(self) -> None: self._watchers -= 1 self._update_acquirer_controller() def _update_acquirer_controller(self) -> None: self._destroy_acquirer_controller() if self._watchers <= 0: return new_acquirer = self._image_acquisition.bn_acquirer.get() if isinstance(new_acquirer, ImageSequenceAcquirer): new_acquirer_controller = ConanImageSequenceAcquirerController( acquirer=new_acquirer, params_factory=self._params_factory, features_service=self._features_service, source_image_out=self.bn_source_image, show_features=self._show_features, ) elif isinstance(new_acquirer, CameraAcquirer): new_acquirer_controller = ConanCameraAcquirerController( acquirer=new_acquirer, params_factory=self._params_factory, features_service=self._features_service, source_image_out=self.bn_source_image, show_features=self._show_features, ) elif new_acquirer is None: new_acquirer_controller = None else: raise ValueError("Unknown acquirer '{}'".format(new_acquirer)) self._acquirer_controller = new_acquirer_controller self.bn_acquirer_controller.poke() def _show_features(self, features: Optional[ConanFeatures]) -> None: if features is None: self.bn_features.set(None) return self.bn_features.set(features) def _destroy_acquirer_controller(self) -> None: acquirer_controller = self._acquirer_controller if acquirer_controller is None: return acquirer_controller.destroy() self._acquirer_controller = None
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