Source code for AFL.automation.vision.RGBCamera

import datetime
import logging
import pathlib
import sys
import time

import lazy_loader as lazy
import numpy as np
import xarray as xr

from AFL.automation.APIServer.Driver import Driver
from AFL.automation.shared.samplecells import NeutronSampleCell


[docs] class RGBCamera(NeutronSampleCell, Driver): """ Driver for capturing RGB images and computing average RGB values. This driver interfaces with a USB camera to capture images and extract the average RGB values along with image metadata. """ defaults = { "camera_index": 0, "save_path": "/home/afl642/rgb_images/", "px_crop": [220, 350], "py_crop": [120, 250], "hough_radii": 40, "subtract_background": True, "show_background_pipeline": False, "background_threshold": 25, "background_capture_on_init": True, "camera_warmup_delay": 0.2, }
[docs] def __init__(self, overrides=None): """ Initialize RGBCamera driver. Parameters ---------- overrides : dict, optional Configuration overrides for PersistentConfig. """ self._opencv_capture = None self.bkg = None self._background_mask = None self._background_meta = {} Driver.__init__( self, name="RGBCamera", defaults=self.gather_defaults(), overrides=overrides, ) self._configure_direct_logging() try: self.open() except ImportError as exc: self.log_warning( "OpenCV is unavailable; RGB camera will remain closed until " f"the vision extra is installed. {exc}" ) if self.config.get("background_capture_on_init", True): try: self.refresh_background() except Exception as exc: self.log_warning(f"Initial background capture failed: {exc}")
def _configure_direct_logging(self): """ Ensure driver logs are visible when the driver is used directly in Python/IPython. """ if self.app is not None: return for handler in self.logger.handlers: if getattr(handler, "_afl_direct_driver_handler", False): return handler = logging.StreamHandler(sys.stdout) handler._afl_direct_driver_handler = True handler.setFormatter(logging.Formatter("[%(name)s] %(levelname)s: %(message)s")) self.logger.addHandler(handler) self.logger.propagate = False def _collect_image(self, **kwargs): """ Collect an image based on the configured camera interface. Returns ------- tuple `(collected, img)` where `collected` indicates success. """ try: cv2_module = lazy.load("cv2", require="AFL-automation[vision]") except Exception as exc: raise ImportError( "opencv-python is required for camera_interface='opencv'. " f"Install with: pip install AFL-automation[vision]. Error: {exc}" ) if "camera_index" not in self.config: raise ValueError("camera_index must be set in config when camera_interface='opencv'") camera_index = self.config["camera_index"] if self._opencv_capture is None or not self._opencv_capture.isOpened(): self.open() return self._opencv_capture.read()
[docs] @Driver.queued() def open(self): """Open the configured OpenCV camera and retain its capture handle.""" if self._opencv_capture is not None and self._opencv_capture.isOpened(): return { "camera_index": self.config.get("camera_index", 0), "opened": True, } self.close() try: cv2_module = lazy.load("cv2", require="AFL-automation[vision]") except Exception as exc: raise ImportError( "opencv-python is required for camera_interface='opencv'. " f"Install with: pip install AFL-automation[vision]. Error: {exc}" ) camera_index = self.config.get("camera_index", 0) self._opencv_capture = cv2_module.VideoCapture(camera_index) return { "camera_index": camera_index, "opened": bool(self._opencv_capture.isOpened()), }
[docs] @Driver.queued() def close(self): """Release the OpenCV camera handle so another process can use it.""" if self._opencv_capture is not None: self._opencv_capture.release() self._opencv_capture = None return {"closed": True}
def _reset_camera(self): """Reset the configured camera connection.""" self.close() self.open() def _capture_processed_frame(self, **kwargs): """ Capture an image and apply the standard crop/circle processing pipeline. Returns ------- tuple `(img, processed)` where `img` is the raw BGR frame and `processed` is the payload returned by `_process_image`. """ px_crop = self.config["px_crop"] py_crop = self.config["py_crop"] hough_radii = self.config["hough_radii"] warmup_delay = self.config.get("camera_warmup_delay", 0.2) self.log_info( "Capturing RGB image with circular ROI detection " f"(px_crop={px_crop}, py_crop={py_crop}, hough_radii={hough_radii})." ) self.log_debug("Attempting to collect camera image.") self.open() time.sleep(warmup_delay) collected, img = self._collect_image(**kwargs) if collected: self.log_debug("Successfully collected camera image.") else: self._reset_camera() self.log_warning("Initial camera capture failed; resetting camera connection and retrying.") time.sleep(warmup_delay) collected, img = self._collect_image(**kwargs) if collected: self.log_info("Camera capture succeeded on retry.") else: raise RuntimeError( "Failed to collect camera image after two attempts. " "Check that the camera is connected and that " f"camera_index ('{self.config.get('camera_index', 0)}') is correct." ) processed = self._process_image(img) return img, processed def _log_rgb_measurement(self, avg_rgb, *, subtract_background, radius, changed_pixel_count=None): """ Emit a concise log message describing how RGB values were obtained. """ if subtract_background: threshold = self.config.get("background_threshold", 25) self.log_info( "Computed RGB using background-subtracted foreground extraction " f"inside the circular ROI (radius={radius}, threshold={threshold}, " f"changed_pixels={changed_pixel_count}). " f"RGB=({avg_rgb['R']:.2f}, {avg_rgb['G']:.2f}, {avg_rgb['B']:.2f})." ) else: self.log_info( "Computed RGB using direct circular ROI averaging " f"(radius={radius}). " f"RGB=({avg_rgb['R']:.2f}, {avg_rgb['G']:.2f}, {avg_rgb['B']:.2f})." ) def _process_image(self, img, px_crop=None, py_crop=None, hough_radii=None): """ Crop the image, locate the circular sample region, and compute masked RGB averages. Parameters ---------- img : np.ndarray Input image in BGR format (from OpenCV). px_crop : list, optional Pixel range [start, end] for cropping along the x-axis. Defaults to the driver's ``px_crop`` configuration. py_crop : list, optional Pixel range [start, end] for cropping along the y-axis. Defaults to the driver's ``py_crop`` configuration. hough_radii : int or list, optional Radius or radii to use for Hough circle detection. Returns ------- dict Processed image payload including cropped image, mask, center, radius, and average RGB values computed inside the mask. """ px_crop = self.config["px_crop"] if px_crop is None else px_crop py_crop = self.config["py_crop"] if py_crop is None else py_crop sample = self.extract_sample_image( img, row_crop=py_crop, col_crop=px_crop, hough_radii=hough_radii, color_order="BGR", ) sample["avg_rgb"] = self.rgb_values( sample["cropped_img"], sample["mask"], color_order="BGR" ) return sample def _process_image_with_background(self, background, image, show=False, threshold=None, roi_mask=None): """ Compute a foreground mask from a stored background image and return RGB averages. Parameters ---------- background : np.ndarray Cropped background image. image : np.ndarray Cropped image containing the object of interest. show : bool, optional If True, save a pyplot view of the subtraction pipeline. threshold : float, optional Difference threshold used to define the foreground mask. roi_mask : np.ndarray, optional Boolean mask restricting subtraction to the circular sample region. Returns ------- dict Background-subtraction results including mask, extracted image, and avg_rgb. """ I1 = np.asarray(background) I2 = np.asarray(image) if I1.shape != I2.shape: raise ValueError( "Background and measurement image shapes must match for subtraction. " f"Got {I1.shape} and {I2.shape}." ) diff = np.abs(I2.astype(np.float32) - I1.astype(np.float32)) if diff.ndim == 3: diff_map = diff.mean(axis=2) else: diff_map = diff if threshold is None: threshold = self.config.get("background_threshold", 25) mask = diff_map > threshold if roi_mask is not None: roi_mask = np.asarray(roi_mask, dtype=bool) if roi_mask.shape != diff_map.shape: raise ValueError( "ROI mask shape must match the cropped image shape for background subtraction. " f"Got {roi_mask.shape} and {diff_map.shape}." ) mask = mask & roi_mask try: from scipy.ndimage import binary_closing, binary_fill_holes, binary_opening except ImportError as exc: raise ImportError( "Background subtraction requires scipy. Install AFL-automation[vision]." ) from exc mask = binary_opening(mask, structure=np.ones((3, 3))) mask = binary_closing(mask, structure=np.ones((5, 5))) mask = binary_fill_holes(mask) if roi_mask is not None: mask = mask & roi_mask changed_pixel_count = int(np.count_nonzero(mask)) if changed_pixel_count < 10: self.log_warning( "Background subtraction detected fewer than 10 changed pixels " f"between the stored background and the captured image ({changed_pixel_count} pixels)." ) if I2.ndim == 3: extracted = np.where(mask[..., None], I2, 0) else: extracted = np.where(mask, I2, 0) avg_rgb = self.rgb_values(extracted, mask, color_order="BGR") pipeline_plot_path = None if show: import matplotlib.pyplot as plt fig, ax = plt.subplots(1, 4, figsize=(16, 4)) ax[0].imshow(I1[:, :, ::-1] if I1.ndim == 3 else I1) ax[0].set_title("Background I1") ax[1].imshow(I2[:, :, ::-1] if I2.ndim == 3 else I2) ax[1].set_title("Image I2") ax[2].imshow(mask, cmap="gray") ax[2].set_title("Mask") ax[3].imshow(extracted[:, :, ::-1] if extracted.ndim == 3 else extracted) ax[3].set_title("Extracted object") for axis in ax: axis.axis("off") plt.tight_layout() save_path = pathlib.Path(self.config.get("save_path", "./")) save_path.mkdir(parents=True, exist_ok=True) pipeline_plot_path = ( save_path / f"{datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}-rgb-background-pipeline.png" ) plt.savefig(pipeline_plot_path, dpi=100, bbox_inches="tight") plt.close(fig) self.log_info(f"Saved background-subtraction pipeline plot to {pipeline_plot_path}.") return { "background": I1, "image": I2, "mask": mask, "changed_pixel_count": changed_pixel_count, "extracted": extracted, "pipeline_plot_path": None if pipeline_plot_path is None else str(pipeline_plot_path), "avg_rgb": avg_rgb, }
[docs] @Driver.unqueued() def refresh_background(self, **kwargs): """ Capture and store a new cropped background reference image. """ _, processed = self._capture_processed_frame(**kwargs) masked_background = np.where( processed["mask"][..., None], processed["cropped_img"], 0, ) self.bkg = masked_background self._background_mask = processed["mask"].copy() self._background_meta = { "cx": processed["cx"], "cy": processed["cy"], "radius": processed["radius"], "shape": processed["cropped_img"].shape, } self.log_info( "Stored new background reference for RGB subtraction " f"(center=({processed['cx']}, {processed['cy']}), radius={processed['radius']})." ) return { "background_ready": True, "shape": list(processed["cropped_img"].shape), "located_center": [processed["cx"], processed["cy"]], "mask_radius": processed["radius"], }
def _build_dataset( self, *, name, avg_rgb, measurement_img, mask, cx, cy, radius, img_metadata, ): """ Build an xarray Dataset containing RGB measurements, mask, and metadata. """ ds = xr.Dataset() ds.attrs["name"] = name ds.attrs["avg_R"] = avg_rgb["R"] ds.attrs["avg_G"] = avg_rgb["G"] ds.attrs["avg_B"] = avg_rgb["B"] ds.attrs["timestamp"] = img_metadata["timestamp"] ds.attrs["image_height"] = img_metadata["height"] ds.attrs["image_width"] = img_metadata["width"] ds.attrs["camera_index"] = self.config.get("camera_index", 0) ds.attrs["located_center"] = [cx, cy] ds.attrs["mask_radius"] = radius ds.attrs["background_subtracted"] = img_metadata.get("background_subtracted", False) ds.attrs["background_available"] = self.bkg is not None ds.attrs["background_threshold"] = img_metadata.get( "background_threshold", self.config.get("background_threshold", 25), ) ds["avg_rgb"] = xr.DataArray( [avg_rgb["R"], avg_rgb["G"], avg_rgb["B"]], coords={"channel": ["R", "G", "B"]}, ) ds["img_bgr"] = (("height", "width", "channel"), measurement_img) ds["mask"] = (("height", "width"), mask) return ds
[docs] @Driver.queued( qb={ "button_text": "Capture RGB", "params": { "name": {"label": "Measurement Name", "type": "text", "default": ""}, "plotting": {"label": "Save diagnostic plots", "type": "bool", "default": False}, "subtract_background": { "label": "Subtract background", "type": "bool", "default": False, }, "show_background_pipeline": { "label": "Show background pipeline", "type": "bool", "default": False, }, }, } ) def capture_rgb( self, name="", plotting=False, subtract_background=False, show_background_pipeline=False, **kwargs, ): """ Capture an image and compute average RGB values. Parameters ---------- name : str, optional Name/identifier for the measurement. plotting : bool, optional If True, save diagnostic plots of the captured image. subtract_background : bool, optional If True, use the stored background reference to compute the average RGB. show_background_pipeline : bool, optional If True, display the background subtraction diagnostic plot. **kwargs : dict Additional arguments passed to image collection. Returns ------- xarray.Dataset Dataset with average RGB values, image, and metadata. """ img, processed = self._capture_processed_frame(**kwargs) subtract_background = subtract_background or self.config.get("subtract_background", False) show_background_pipeline = show_background_pipeline or self.config.get( "show_background_pipeline", False ) background_processed = None avg_rgb = processed["avg_rgb"] if subtract_background: if self.bkg is None: self.refresh_background(**kwargs) roi_mask = processed["mask"] if self._background_mask is not None and self._background_mask.shape == processed["mask"].shape: roi_mask = roi_mask & self._background_mask background_processed = self._process_image_with_background( self.bkg, np.where(processed["mask"][..., None], processed["cropped_img"], 0), show=show_background_pipeline, roi_mask=roi_mask, ) avg_rgb = background_processed["avg_rgb"] self._log_rgb_measurement( avg_rgb, subtract_background=subtract_background, radius=processed["radius"], changed_pixel_count=( None if background_processed is None else background_processed["changed_pixel_count"] ), ) img_metadata = { "timestamp": datetime.datetime.now().isoformat(), "height": processed["cropped_img"].shape[0], "width": processed["cropped_img"].shape[1], "background_subtracted": subtract_background, "background_threshold": self.config.get("background_threshold", 25), } ds = self._build_dataset( name=name, avg_rgb=avg_rgb, measurement_img=processed["cropped_img"], mask=processed["mask"], cx=processed["cx"], cy=processed["cy"], radius=processed["radius"], img_metadata=img_metadata, ) if plotting: try: save_path = pathlib.Path(self.config.get("save_path", "./")) plot_file = self.save_geometry_plot( img, processed, save_path=save_path, filename=f"{datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}-rgb-capture.png", title="Detected neutron sample cell", color_order="BGR", show_full_image_axes=True, full_image_x_label="px", full_image_y_label="py", overlay_mask=( None if background_processed is None else background_processed["mask"] ), ) self.log_info(f"Saved RGB capture diagnostic plot to {plot_file}.") except Exception as e: self.log_warning(f"Could not save RGB capture diagnostic plot: {e}") return ds
_DEFAULT_CUSTOM_CONFIG = { "_classname": "AFL.automation.vision.RGBCamera.RGBCamera", "overrides": { "camera_index": 0, "px_crop": [220, 350], "py_crop": [120, 250], "hough_radii": 40, "save_path": "/home/afl642/rgb_camera/", "subtract_background": True, "show_background_pipeline": False, "background_threshold": 25, "background_capture_on_init": True, "camera_warmup_delay": 0.2, } } _DEFAULT_CUSTOM_PORT = 5095 if __name__ == "__main__": from AFL.automation.shared.launcher import *