Source code for AFL.automation.instrument.RGBCamera

import datetime
import pathlib
import time

import lazy_loader as lazy
import numpy as np
import xarray as xr
from skimage.color import rgb2gray
from skimage.feature import canny
from skimage.transform import hough_circle, hough_circle_peaks
from skimage.util import img_as_ubyte

from AFL.automation.APIServer.Driver import Driver

cv2 = lazy.load("cv2", require="AFL-automation[vision]")


[docs] class RGBCamera(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 = {} defaults["camera_index"] = 0 defaults["save_path"] = "/home/afl642/rgb_images/" defaults["px_crop"] = [220, 350] defaults["py_crop"] = [120, 250] defaults["hough_radii"] = 40
[docs] def __init__(self, overrides=None): """ Initialize RGBCamera driver. Parameters ---------- overrides : dict, optional Configuration overrides for PersistentConfig. """ self._opencv_capture = None Driver.__init__( self, name="RGBCamera", defaults=self.gather_defaults(), overrides=overrides, )
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: self._opencv_capture = cv2_module.VideoCapture(camera_index) return self._opencv_capture.read() def _reset_camera(self): """Reset the configured camera connection.""" if self._opencv_capture is not None: self._opencv_capture.release() 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) 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. If None, uses full image. py_crop : list, optional Pixel range [start, end] for cropping along the y-axis. If None, uses full image. 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. """ if px_crop is None: px_crop = [0, img.shape[1]] if py_crop is None: py_crop = [0, img.shape[0]] if hough_radii is None: hough_radii = self.config.get("hough_radii", 98) cropped_img = img[py_crop[0] : py_crop[1], px_crop[0] : px_crop[1], :] gray_img = img_as_ubyte(rgb2gray(cropped_img)) radii = list(np.atleast_1d(hough_radii)) edges = canny(gray_img, sigma=2, low_threshold=10, high_threshold=50) hough_res = hough_circle(edges, radii) _, cx, cy, detected_radii = hough_circle_peaks(hough_res, radii, total_num_peaks=1) if len(cx) == 0 or len(cy) == 0 or len(detected_radii) == 0: raise RuntimeError( "Failed to locate sample region with Hough circle detection. " "Adjust px_crop, py_crop, or hough_radii." ) cx = int(cx[0]) cy = int(cy[0]) detected_radius = int(detected_radii[0]) y = np.arange(cropped_img.shape[0]) x = np.arange(cropped_img.shape[1]) X, Y = np.meshgrid(x, y) mask = np.sqrt((X - cx) * (X - cx) + (Y - cy) * (Y - cy)) < detected_radius avg_blue = np.mean(cropped_img[:, :, 0][mask]) avg_green = np.mean(cropped_img[:, :, 1][mask]) avg_red = np.mean(cropped_img[:, :, 2][mask]) return { "cropped_img": cropped_img, "gray_img": gray_img, "mask": mask, "cx": cx, "cy": cy, "radius": detected_radius, "avg_rgb": { "R": float(avg_red), "G": float(avg_green), "B": float(avg_blue), }, } 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["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}, }, } ) def capture_rgb(self, name="", plotting=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. **kwargs : dict Additional arguments passed to image collection. Returns ------- xarray.Dataset Dataset with average RGB values, image, and metadata. """ px_crop = self.config.get("px_crop", [0, 479]) py_crop = self.config.get("py_crop", [0, 479]) hough_radii = self.config.get("hough_radii", 98) print(f"Capturing RGB image (crops: px {px_crop}, py {py_crop})") print(f"Using hough_radii={hough_radii}") print("Attempting to collect camera image") self._reset_camera() time.sleep(0.2) collected, img = self._collect_image(**kwargs) if collected: print("Successfully collected image") else: self._reset_camera() print("Attempting to reset camera connection and retry") time.sleep(0.2) collected, img = self._collect_image(**kwargs) if collected: print("Success on retry") else: raise RuntimeError( "Failed to collect camera image after two attempts. " "Check that the camera is connected and the " f"camera_interface ('{self.config['camera_interface']}') " "settings are correct." ) processed = self._process_image( img, px_crop=px_crop, py_crop=py_crop, hough_radii=hough_radii, ) avg_rgb = processed["avg_rgb"] print(f"Average RGB: R={avg_rgb['R']:.2f}, G={avg_rgb['G']:.2f}, B={avg_rgb['B']:.2f}") img_metadata = { "timestamp": datetime.datetime.now().isoformat(), "height": processed["cropped_img"].shape[0], "width": processed["cropped_img"].shape[1], } 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: import numpy as np import matplotlib.pyplot as plt from matplotlib.patches import Circle, Rectangle fig, axs = plt.subplots(1, 2, figsize=(6*2, 6*1)) axs[0].imshow(img) axs[0].add_patch( Rectangle( (px_crop[0], py_crop[0]), # (x, y) px_crop[1] - px_crop[0], # width py_crop[1] - py_crop[0], # height edgecolor="red", facecolor="none", linewidth=2) ) axs[0].set_xlim(0, img.shape[1]) axs[0].set_ylim(img.shape[0], 0) # keep image-style orientation axs[0].set_title("Captured image with crop region", pad=20) axs[0].axis("off") img_rgb = ds["img_bgr"].values[:, :, ::-1] cx, cy = ds.attrs["located_center"] radius = ds.attrs["mask_radius"] rgb = ds["avg_rgb"].values / 255.0 color_block = np.ones((20, 40, 3)) * rgb axs[1].imshow(img_rgb) axs[1].add_patch(Circle((cx, cy), radius, edgecolor="red", facecolor="none", linewidth=2)) axs[1].axis("off") axs[1].set_title( f"Detected circle\nRGB = [{ds['avg_rgb'][0].item():.1f}, {ds['avg_rgb'][1].item():.1f}, {ds['avg_rgb'][2].item():.1f}]", pad=20, ) swatch_ax = axs[1].inset_axes([0.4, 1.02, 0.2, 0.08]) # [x0, y0, width, height] in axes coords swatch_ax.imshow(color_block) swatch_ax.set_xticks([]) swatch_ax.set_yticks([]) for spine in swatch_ax.spines.values(): spine.set_edgecolor("black") spine.set_linewidth(1) save_path = pathlib.Path(self.config.get("save_path", "./")) save_path.mkdir(parents=True, exist_ok=True) plot_file = ( save_path / f"{datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}-rgb-capture.png" ) plt.savefig(plot_file, dpi=100, bbox_inches="tight") plt.close(fig) print(f"Saved plot to {plot_file}") except Exception as e: print(f"Warning: Could not save plot: {e}") return ds
_DEFAULT_CUSTOM_CONFIG = { "_classname": "AFL.automation.instrument.RGBCamera.RGBCamera", "overrides": { "camera_index": 0, "px_crop": [220, 350], "py_crop": [120, 250], "hough_radii": 40, "save_path": "/home/afl642/rgb_images/" } } _DEFAULT_CUSTOM_PORT = 5095 if __name__ == "__main__": from AFL.automation.shared.launcher import *