From 5566125e6ecba42fcd77f31c930858fb659652cf Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 17 Sep 2025 13:59:00 +0100 Subject: [PATCH 1/9] Update picamera file to HQ parameters --- .../things/camera/picamera.py | 2 +- .../camera/picamera_recalibrate_utils.py | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index e8ab2e2f..efc1649f 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -646,7 +646,7 @@ class StreamingPiCamera2(BaseCamera): @lt.thing_action def auto_expose_from_minimum( self, - target_white_level: int = 700, + target_white_level: int = 3000, percentile: float = 99.9, ) -> None: """Adjust exposure until a the target white level is reached. diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index 44f269f6..24fea7d8 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -89,7 +89,7 @@ def set_minimum_exposure(camera: Picamera2) -> None: # Setting the shutter speed to 1us will result in it being set # to the minimum possible, which is ~8us for PiCamera v2 camera.set_controls({"AeEnable": False, "AnalogueGain": 1, "ExposureTime": 1}) - time.sleep(0.5) + time.sleep(1) class ExposureTest(BaseModel): @@ -142,7 +142,7 @@ def check_convergence(test: ExposureTest, target: int, tolerance: float) -> bool def adjust_shutter_and_gain_from_raw( camera: Picamera2, - target_white_level: int = 700, + target_white_level: int = 3000, max_iterations: int = 20, tolerance: float = 0.05, percentile: float = 99.9, @@ -167,14 +167,14 @@ def adjust_shutter_and_gain_from_raw( """ # TODO: read black level and bit depth from camera? - if target_white_level * (tolerance + 1) >= 959: + if target_white_level * (tolerance + 1) >= 3850: raise ValueError( "The target level is too high - a saturated image would be " "considered successful. target_white_level * (tolerance + 1) " - "must be less than 959." + "must be less than 3850." ) - config = camera.create_still_configuration(raw={"format": "SBGGR10"}) + config = camera.create_still_configuration(raw={"format": "SBGGR12"}) camera.configure(config) camera.start() set_minimum_exposure(camera) @@ -194,7 +194,7 @@ def adjust_shutter_and_gain_from_raw( new_time = int(test.exposure_time * min(target_white_level / test.level, 8)) camera.controls.ExposureTime = new_time camera.controls.AeEnable = False - time.sleep(0.5) + time.sleep(1) # Check whether the shutter speed is still going up - if not, we've hit a maximum if camera.capture_metadata()["ExposureTime"] == test.exposure_time: @@ -212,7 +212,7 @@ def adjust_shutter_and_gain_from_raw( camera.controls.AnalogueGain = test.analog_gain * min( target_white_level / test.level, 2 ) - time.sleep(0.5) + time.sleep(1) # Check the gain is still changing - if not, we have probably hit the maximum if camera.capture_metadata()["AnalogueGain"] == test.analog_gain: @@ -245,12 +245,12 @@ def adjust_white_balance_from_raw( We should probably have better logic to verify the channels really are BGGR... """ - config = camera.create_still_configuration(raw={"format": "SBGGR10"}) + config = camera.create_still_configuration(raw={"format": "SBGGR12"}) camera.configure(config) camera.start() channels = channels_from_bayer_array(camera.capture_array("raw")) # TODO: read black level from camera rather than hard-coding 64 - blacklevel = 64 + blacklevel = 256 if luminance is not None and Cr is not None and Cb is not None: # Reconstruct a low-resolution image from the lens shading tables # and use it to normalise the raw image, to compensate for @@ -297,7 +297,7 @@ def adjust_white_balance_from_raw( ) camera.controls.AwbEnable = False camera.controls.ColourGains = new_awb_gains - time.sleep(0.2) + time.sleep(1) m = camera.capture_metadata() print(f"Camera confirms gains are now {m['ColourGains']}") return new_awb_gains @@ -354,7 +354,7 @@ def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: def downsampled_channels( - channels: np.ndarray, blacklevel: int = 64 + channels: np.ndarray, blacklevel: int = 256 ) -> list[np.ndarray]: """Generate a downsampled, un-normalised image from which to calculate the LST. @@ -534,7 +534,7 @@ def raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: # format below requests. Bit depth and Bayer order may be overwritten. # TODO: don't assume 10-bit - the high quality camera uses 12. # TODO: what's the best mode to use here? - config = camera.create_still_configuration(raw={"format": "SBGGR10"}) + config = camera.create_still_configuration(raw={"format": "SBGGR12"}) camera.configure(config) camera.start() raw_image = camera.capture_array("raw") From c16a0391df80cbef0f66faab9b041570c7a9b334 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Sep 2025 21:18:48 +0100 Subject: [PATCH 2/9] Divide up tuning file modification from camera actions for recalibration. * Ensure that funtions only called locally by recalibrate utils are private * Move public API to the top of the recalibrate utils file * Move files that only alter the tuning dictionary to their own file This makes the recalibration API used by PiCamera much more clearly defined, which helps to plan out how to allow for different models. --- .../picamera2/test_tuning.py | 13 +- .../things/camera/picamera.py | 24 +- .../camera/picamera_recalibrate_utils.py | 300 +++++------------- .../camera/picamera_tuning_file_utils.py | 134 ++++++++ 4 files changed, 238 insertions(+), 233 deletions(-) create mode 100644 src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py diff --git a/hardware-specific-tests/picamera2/test_tuning.py b/hardware-specific-tests/picamera2/test_tuning.py index 35a67676..5932af6f 100644 --- a/hardware-specific-tests/picamera2/test_tuning.py +++ b/hardware-specific-tests/picamera2/test_tuning.py @@ -8,20 +8,17 @@ from picamera2 import Picamera2 from openflexure_microscope_server.things.camera import ( picamera_recalibrate_utils as recalibrate_utils, ) +from openflexure_microscope_server.things.camera import ( + picamera_tuning_file_utils as tf_utils, +) MODEL = Picamera2.global_camera_info()[0]["Model"] -def load_default_tuning(): - """Return the default tuning file for the connected camera.""" - fname = f"{MODEL}.json" - return Picamera2.load_tuning_file(fname) - - def generate_bad_tuning(): """Return a tuning file with an invalid version number to force an error when loaded.""" - default_tuning = load_default_tuning() + default_tuning = tf_utils.load_default_tuning() bad_tuning = default_tuning.copy() bad_tuning["version"] = 999 return bad_tuning @@ -58,7 +55,7 @@ def _test_bad_tuning_after_good_tuning(configure: bool = False): PiCamera2 behaviour does not expect the tuning file to be reloaded. """ bad_tuning = generate_bad_tuning() - default_tuning = load_default_tuning() + default_tuning = tf_utils.load_default_tuning() print_tuning() print("opening camera with default tuning") with Picamera2(tuning=default_tuning) as cam: diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index efc1649f..a6096372 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -43,6 +43,8 @@ from openflexure_microscope_server.ui import ( property_control_for, ) from . import picamera_recalibrate_utils as recalibrate_utils +from . import picamera_tuning_file_utils as tf_utils + from . import BaseCamera, ArrayModel @@ -144,7 +146,7 @@ class StreamingPiCamera2(BaseCamera): self._picamera = None logging.info("Starting & reconfiguring camera to populate sensor_modes.") with Picamera2(camera_num=self.camera_num) as cam: - self.default_tuning = recalibrate_utils.load_default_tuning(cam) + self.default_tuning = tf_utils.load_default_tuning(cam) logging.info("Done reading sensor modes & default tuning.") # Set tuning to default tuning. This will be overwritten when the Thing is # connects to the server if tuning is saved to disk. @@ -718,7 +720,7 @@ class StreamingPiCamera2(BaseCamera): # luminance (L), red-difference chroma (Cr), and blue-difference chroma # (Cb). L, Cr, Cb = recalibrate_utils.lst_from_camera(cam) # noqa: N806 - recalibrate_utils.set_static_lst(self.tuning, L, Cr, Cb) + tf_utils.set_static_lst(self.tuning, L, Cr, Cb) self._initialise_picamera() @lt.thing_property @@ -735,14 +737,14 @@ class StreamingPiCamera2(BaseCamera): See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45. """ - return tuple(recalibrate_utils.get_static_ccm(self.tuning)[0]["ccm"]) + return tuple(tf_utils.get_static_ccm(self.tuning)[0]["ccm"]) @colour_correction_matrix.setter # type: ignore def colour_correction_matrix( self, value: tuple[float, float, float, float, float, float, float, float, float], ) -> None: - recalibrate_utils.set_static_ccm(self.tuning, value) + tf_utils.set_static_ccm(self.tuning, value) if self._picamera is not None: with self._streaming_picamera(pause_stream=True): @@ -781,7 +783,7 @@ class StreamingPiCamera2(BaseCamera): A value of 0 here does nothing, a value of 65535 is maximum correction. """ with self._streaming_picamera(pause_stream=True): - recalibrate_utils.set_static_geq(self.tuning, offset) + tf_utils.set_static_geq(self.tuning, offset) self._initialise_picamera() @lt.thing_action @@ -820,9 +822,7 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera(pause_stream=True): # Generate and array of ones of the correct size for each channel flat_array = np.ones((12, 16)) - recalibrate_utils.set_static_lst( - self.tuning, flat_array, flat_array, flat_array - ) + tf_utils.set_static_lst(self.tuning, flat_array, flat_array, flat_array) self._initialise_picamera() @lt.thing_property @@ -945,7 +945,7 @@ class StreamingPiCamera2(BaseCamera): def lens_shading_tables(self, lst: LensShading) -> None: """Set the lens shading tables.""" with self._streaming_picamera(pause_stream=True): - recalibrate_utils.set_static_lst( + tf_utils.set_static_lst( self.tuning, luminance=lst.luminance, cr=lst.Cr, @@ -996,7 +996,7 @@ class StreamingPiCamera2(BaseCamera): alsc = self.get_tuning_algo("rpi.alsc") luminance = alsc["luminance_lut"] flat = np.ones((12, 16)) - recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat) + tf_utils.set_static_lst(self.tuning, luminance, flat, flat) self._initialise_picamera() @lt.thing_action @@ -1007,7 +1007,7 @@ class StreamingPiCamera2(BaseCamera): by the Raspberry Pi camera. """ with self._streaming_picamera(pause_stream=True): - recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) + tf_utils.copy_alsc_section(self.default_tuning, self.tuning) self._initialise_picamera() @lt.thing_property @@ -1019,4 +1019,4 @@ class StreamingPiCamera2(BaseCamera): The default LST is not static, but all the calibration controls will set it to be static (except "reset") """ - return recalibrate_utils.lst_is_static(self.tuning) + return tf_utils.lst_is_static(self.tuning) diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index 24fea7d8..837ba4a7 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -1,4 +1,4 @@ -"""Functions to set up a Raspberry Pi Camera v2 for scientific use. +"""Functions to set up a Raspberry Pi Camera (v2 and HQ) for scientific use. This module provides slower, simpler functions to set the gain, exposure, and white balance of a Raspberry Pi camera, using @@ -51,30 +51,6 @@ import picamera2 LensShadingTables = tuple[np.ndarray, np.ndarray, np.ndarray] -def load_default_tuning(cam: Picamera2) -> dict: - """Load the default tuning file for the camera. - - This will open and close the camera to determine its model. If you are - using a model that's supported by ``picamera2`` it should have a tuning - file built in. If not, this will probably crash with an error. - - Error handling for unsupported cameras is not something we are likely - to test in the short term. - """ - cp = cam.camera_properties - fname = f"{cp['Model']}.json" - try: - return cam.load_tuning_file(fname) - except RuntimeError: - tuning_dir = "/usr/share/libcamera/ipa/raspberrypi" - # from picamera2 v0.3.9 - # The directory above has been removed from the search path seems - # odd - as that's where the files currently are on a default - # Raspbian image. This may need updating if the files have moved - # in future updates to the system libcamera package - return cam.load_tuning_file(fname, dir=tuning_dir) - - def set_minimum_exposure(camera: Picamera2) -> None: """Enable manual exposure, with low gain and shutter speed. @@ -92,54 +68,6 @@ def set_minimum_exposure(camera: Picamera2) -> None: time.sleep(1) -class ExposureTest(BaseModel): - """Record the results of testing the camera's current exposure settings.""" - - level: int - exposure_time: int - analog_gain: float - - -def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest: - """Evaluate current exposure settings using a raw image. - - CAMERA SHOULD BE STARTED! - - We will acquire a raw image and calculate the given percentile - of the pixel values. We return a dictionary containing the - percentile (which will be compared to the target), as well as - the camera's shutter and gain values. - """ - camera.capture_array("raw") # controls might not be updated for the first frame? - max_brightness = np.percentile( - channels_from_bayer_array(camera.capture_array("raw")), - percentile, - ) - # The reported brightness can, theoretically, be negative or zero - # because of black level compensation. The line below forces a - # minimum value of 1 which will keep things well-behaved! - if max_brightness < 1: - logging.warning( - f"Measured brightness of {max_brightness}. " - "This should normally be >= 1, and may indicate the " - "camera's black level compensation has gone wrong." - ) - max_brightness = 1 - metadata = camera.capture_metadata() - result = ExposureTest( - level=max_brightness, - exposure_time=int(metadata["ExposureTime"]), - analog_gain=float(metadata["AnalogueGain"]), - ) - logging.info(f"{result.model_dump()}") - return result - - -def check_convergence(test: ExposureTest, target: int, tolerance: float) -> bool: - """Check whether the brightness is within the specified target range.""" - return abs(test.level - target) < target * tolerance - - def adjust_shutter_and_gain_from_raw( camera: Picamera2, target_white_level: int = 3000, @@ -184,8 +112,8 @@ def adjust_shutter_and_gain_from_raw( # shutter speed any more. iterations = 0 while iterations < max_iterations: - test = test_exposure_settings(camera, percentile) - if check_convergence(test, target_white_level, tolerance): + test = _test_exposure_settings(camera, percentile) + if _check_convergence(test, target_white_level, tolerance): break iterations += 1 @@ -203,8 +131,8 @@ def adjust_shutter_and_gain_from_raw( # Now, if we've not converged, increase gain until we converge or run out of options. while iterations < max_iterations: - test = test_exposure_settings(camera, percentile) - if check_convergence(test, target_white_level, tolerance): + test = _test_exposure_settings(camera, percentile) + if _check_convergence(test, target_white_level, tolerance): break iterations += 1 @@ -219,7 +147,7 @@ def adjust_shutter_and_gain_from_raw( logging.info(f"Gain has maxed out at {test.analog_gain}") break - if check_convergence(test, target_white_level, tolerance): + if _check_convergence(test, target_white_level, tolerance): logging.info(f"Brightness has converged to within {tolerance * 100:.0f}%.") else: logging.warning( @@ -248,17 +176,17 @@ def adjust_white_balance_from_raw( config = camera.create_still_configuration(raw={"format": "SBGGR12"}) camera.configure(config) camera.start() - channels = channels_from_bayer_array(camera.capture_array("raw")) + channels = _channels_from_bayer_array(camera.capture_array("raw")) # TODO: read black level from camera rather than hard-coding 64 blacklevel = 256 if luminance is not None and Cr is not None and Cb is not None: # Reconstruct a low-resolution image from the lens shading tables # and use it to normalise the raw image, to compensate for # the brightest pixels in each channel not coinciding. - grids = grids_from_lst(np.array(luminance) ** luminance_power, Cr, Cb) + grids = _grids_from_lst(np.array(luminance) ** luminance_power, Cr, Cb) channel_gains = 1 / grids if channel_gains.shape[1:] != channels.shape[1:]: - channel_gains = upsample_channels(channel_gains, channels.shape[1:]) + channel_gains = _upsample_channels(channel_gains, channels.shape[1:]) logging.info( f"Before gains, channel maxima are {np.max(channels, axis=(1, 2))}" ) @@ -303,7 +231,71 @@ def adjust_white_balance_from_raw( return new_awb_gains -def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray: +def lst_from_camera(camera: Picamera2) -> LensShadingTables: + """Acquire a raw image and use it to calculate a lens shading table.""" + channels = _raw_channels_from_camera(camera) + return _lst_from_channels(channels) + + +def recreate_camera_manager() -> None: + """Delete and recreate the camera manager. + + This is necessary to ensure the tuning file is re-read. + """ + del Picamera2._cm + gc.collect() + Picamera2._cm = picamera2.picamera2.CameraManager() + + +class _ExposureTest(BaseModel): + """Record the results of testing the camera's current exposure settings.""" + + level: int + exposure_time: int + analog_gain: float + + +def _test_exposure_settings(camera: Picamera2, percentile: float) -> _ExposureTest: + """Evaluate current exposure settings using a raw image. + + CAMERA SHOULD BE STARTED! + + We will acquire a raw image and calculate the given percentile + of the pixel values. We return a dictionary containing the + percentile (which will be compared to the target), as well as + the camera's shutter and gain values. + """ + camera.capture_array("raw") # controls might not be updated for the first frame? + max_brightness = np.percentile( + _channels_from_bayer_array(camera.capture_array("raw")), + percentile, + ) + # The reported brightness can, theoretically, be negative or zero + # because of black level compensation. The line below forces a + # minimum value of 1 which will keep things well-behaved! + if max_brightness < 1: + logging.warning( + f"Measured brightness of {max_brightness}. " + "This should normally be >= 1, and may indicate the " + "camera's black level compensation has gone wrong." + ) + max_brightness = 1 + metadata = camera.capture_metadata() + result = _ExposureTest( + level=max_brightness, + exposure_time=int(metadata["ExposureTime"]), + analog_gain=float(metadata["AnalogueGain"]), + ) + logging.info(f"{result.model_dump()}") + return result + + +def _check_convergence(test: _ExposureTest, target: int, tolerance: float) -> bool: + """Check whether the brightness is within the specified target range.""" + return abs(test.level - target) < target * tolerance + + +def _channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray: """Given the 'array' from a PiBayerArray, return the 4 channels.""" bayer_pattern: List[Tuple[int, int]] = [(0, 0), (0, 1), (1, 0), (1, 1)] bayer_array = bayer_array.view(np.uint16) @@ -320,7 +312,7 @@ def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray: return channels -def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray: +def _get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray: """Compresses channel down to a 16x12 grid - from libcamera. This is taken from @@ -342,10 +334,10 @@ def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray: return np.reshape(np.array(grid), (12, 16)) -def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: +def _upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: """Zoom an image in the last two dimensions. - This is effectively the inverse operation of ``get_16x12_grid`` + This is effectively the inverse operation of ``_get_16x12_grid`` """ zoom_factors = [ 1, @@ -353,7 +345,7 @@ def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: return zoom(grids, zoom_factors, order=1)[:, : shape[0], : shape[1]] -def downsampled_channels( +def _downsampled_channels( channels: np.ndarray, blacklevel: int = 256 ) -> list[np.ndarray]: """Generate a downsampled, un-normalised image from which to calculate the LST. @@ -365,7 +357,7 @@ def downsampled_channels( step = np.ceil(channel_shape / lst_shape).astype(int) return np.stack( [ - get_16x12_grid( + _get_16x12_grid( channels[i, ...].astype(float) - blacklevel, step[1], step[0] ) for i in range(channels.shape[0]) @@ -374,16 +366,16 @@ def downsampled_channels( ) -def lst_from_channels(channels: np.ndarray) -> LensShadingTables: +def _lst_from_channels(channels: np.ndarray) -> LensShadingTables: """Given the 4 Bayer colour channels from a white image, generate a LST. - Internally, is just calls ``downsampled_channels`` and ``lst_from_grids``. + Internally, is just calls ``_downsampled_channels`` and ``_lst_from_grids``. """ - grids = downsampled_channels(channels) - return lst_from_grids(grids) + grids = _downsampled_channels(channels) + return _lst_from_grids(grids) -def lst_from_grids(grids: np.ndarray) -> LensShadingTables: +def _lst_from_grids(grids: np.ndarray) -> LensShadingTables: """Given 4 downsampled grids, generate the luminance and chrominance tables. The grids are the 4 BAYER channels RGGB @@ -409,7 +401,7 @@ def lst_from_grids(grids: np.ndarray) -> LensShadingTables: return luminance_gains, cr_gains, cb_gains -def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray: +def _grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray: """Convert form luminance/chrominance dict to four RGGB channels. Note that these will be normalised - the maximum green value is always 1. @@ -423,110 +415,7 @@ def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarra return np.stack([B, G, G, R], axis=0) -def set_static_lst( - tuning: dict, - luminance: np.ndarray, - cr: np.ndarray, - cb: np.ndarray, -) -> None: - """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. - - ``tuning`` will be updated in-place to set its shading to static, and disable any - adaptive tweaking by the algorithm. - """ - for table in luminance, cr, cb: - assert np.array(table).shape == (12, 16), "Lens shading tables must be 12x16!" - alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") - alsc["n_iter"] = 0 # disable the adaptive part - alsc["luminance_strength"] = 1.0 - alsc["calibrations_Cr"] = [ - {"ct": 4500, "table": as_flat_rounded_list(cr, round_to=3)} - ] - alsc["calibrations_Cb"] = [ - {"ct": 4500, "table": as_flat_rounded_list(cb, round_to=3)} - ] - alsc["luminance_lut"] = as_flat_rounded_list(luminance, round_to=3) - - -def set_static_ccm( - tuning: dict, - col_corr_matrix: tuple[ - float, float, float, float, float, float, float, float, float - ], -) -> None: - """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. - - ``tuning`` will be updated in-place to set its shading to static, and disable any - adaptive tweaking by the algorithm. - """ - ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") - ccm["ccms"] = [{"ct": 2860, "ccm": col_corr_matrix}] - - -def get_static_ccm(tuning: dict) -> None: - """Get the ``rpi.ccm`` section of a camera tuning dict.""" - ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") - return ccm["ccms"] - - -def lst_is_static(tuning: dict) -> bool: - """Whether the lens shading table is set to static.""" - alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") - return alsc["n_iter"] == 0 - - -def set_static_geq( - tuning: dict, - offset: int = 65535, -) -> None: - """Update the ``rpi.geq`` section of a camera tuning dict. - - :param tuning: the raspberry pi tuning file. This will be updated in-place to - set the geq offset to the given value. - :param offset: The desired green equalisation offset. Default 65535. The default is - the maximum allowed value. This means the brightness will always be below the - threshold where averaging is used. This is default as we always need the green - equalisation to averages the green pixels in the red and blue rows due to the - chief ray angle compensation issue when the the stock lens is replaced by an - objective. - """ - geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") - geq["offset"] = offset # max out offset to disable the adaptive green equalisation - - -def _geq_is_static(tuning: dict) -> bool: - """Whether the green equalisation is set to static.""" - geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") - return geq["offset"] == 65535 - - -def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int: - """Find the index of an algorithm's section in the tuning file.""" - for i, a in enumerate(algorithms): - if algorithm in a: - return i - raise ValueError(f"Algorithm {algorithm} is not available.") - - -def copy_alsc_section(from_tuning: dict, to_tuning: dict) -> None: - """Copy the ``rpi.alsc`` algorithm from one tuning to another. - - This is done in-place, i.e. modifying to_tuning. - """ - # Using Picamera2 function to find the relevant sub-dict for each tuning file - from_i = index_of_algorithm(from_tuning["algorithms"], "rpi.alsc") - to_i = index_of_algorithm(to_tuning["algorithms"], "rpi.alsc") - # Updating the dictionary in place. - to_tuning["algorithms"][to_i] = from_tuning["algorithms"][from_i] - - -def lst_from_camera(camera: Picamera2) -> LensShadingTables: - """Acquire a raw image and use it to calculate a lens shading table.""" - channels = raw_channels_from_camera(camera) - return lst_from_channels(channels) - - -def raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: +def _raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: """Acquire a raw image and return a 4xNxM array of the colour channels.""" if camera.started: camera.stop_recording() @@ -545,19 +434,4 @@ def raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: # channels, 1/2 for green because there's twice as many green pixels). raw_format = camera.camera_configuration()["raw"]["format"] print(f"Acquired a raw image in format {raw_format}") - return channels_from_bayer_array(raw_image) - - -def recreate_camera_manager() -> None: - """Delete and recreate the camera manager. - - This is necessary to ensure the tuning file is re-read. - """ - del Picamera2._cm - gc.collect() - Picamera2._cm = picamera2.picamera2.CameraManager() - - -def as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]: - """Flatten array, round, and then convert to list.""" - return np.reshape(array, -1).round(round_to).tolist() + return _channels_from_bayer_array(raw_image) diff --git a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py new file mode 100644 index 00000000..95df25b1 --- /dev/null +++ b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py @@ -0,0 +1,134 @@ +"""Functions for loading, adjusting, or reading from the Picamera2 tuning file. + +The functions that edit the tuning files edit them in place. This will change in +the future. +""" + +from picamera2 import Picamera2 +import numpy as np + + +def load_default_tuning(cam: Picamera2) -> dict: + """Load the default tuning file for the camera. + + This will open and close the camera to determine its model. If you are + using a model that's supported by ``picamera2`` it should have a tuning + file built in. If not, this will probably crash with an error. + + Error handling for unsupported cameras is not something we are likely + to test in the short term. + """ + cp = cam.camera_properties + fname = f"{cp['Model']}.json" + try: + return cam.load_tuning_file(fname) + except RuntimeError: + tuning_dir = "/usr/share/libcamera/ipa/raspberrypi" + # from picamera2 v0.3.9 + # The directory above has been removed from the search path seems + # odd - as that's where the files currently are on a default + # Raspbian image. This may need updating if the files have moved + # in future updates to the system libcamera package + return cam.load_tuning_file(fname, dir=tuning_dir) + + +def set_static_lst( + tuning: dict, + luminance: np.ndarray, + cr: np.ndarray, + cb: np.ndarray, +) -> None: + """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. + + ``tuning`` will be updated in-place to set its shading to static, and disable any + adaptive tweaking by the algorithm. + """ + for table in luminance, cr, cb: + assert np.array(table).shape == (12, 16), "Lens shading tables must be 12x16!" + alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") + alsc["n_iter"] = 0 # disable the adaptive part + alsc["luminance_strength"] = 1.0 + alsc["calibrations_Cr"] = [ + {"ct": 4500, "table": _as_flat_rounded_list(cr, round_to=3)} + ] + alsc["calibrations_Cb"] = [ + {"ct": 4500, "table": _as_flat_rounded_list(cb, round_to=3)} + ] + alsc["luminance_lut"] = _as_flat_rounded_list(luminance, round_to=3) + + +def set_static_ccm( + tuning: dict, + col_corr_matrix: tuple[ + float, float, float, float, float, float, float, float, float + ], +) -> None: + """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correction. + + ``tuning`` will be updated in-place to set its shading to static, and disable any + adaptive tweaking by the algorithm. + """ + ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") + ccm["ccms"] = [{"ct": 2860, "ccm": col_corr_matrix}] + + +def get_static_ccm(tuning: dict) -> None: + """Get the ``rpi.ccm`` section of a camera tuning dict.""" + ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") + return ccm["ccms"] + + +def lst_is_static(tuning: dict) -> bool: + """Whether the lens shading table is set to static.""" + alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") + return alsc["n_iter"] == 0 + + +def set_static_geq( + tuning: dict, + offset: int = 65535, +) -> None: + """Update the ``rpi.geq`` section of a camera tuning dict. + + :param tuning: the raspberry pi tuning file. This will be updated in-place to + set the geq offset to the given value. + :param offset: The desired green equalisation offset. Default 65535. The default is + the maximum allowed value. This means the brightness will always be below the + threshold where averaging is used. This is default as we always need the green + equalisation to averages the green pixels in the red and blue rows due to the + chief ray angle compensation issue when the the stock lens is replaced by an + objective. + """ + geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") + geq["offset"] = offset # max out offset to disable the adaptive green equalisation + + +def geq_is_static(tuning: dict) -> bool: + """Whether the green equalisation is set to static.""" + geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") + return geq["offset"] == 65535 + + +def copy_alsc_section(from_tuning: dict, to_tuning: dict) -> None: + """Copy the ``rpi.alsc`` algorithm from one tuning to another. + + This is done in-place, i.e. modifying to_tuning. + """ + # Using Picamera2 function to find the relevant sub-dict for each tuning file + from_i = _index_of_algorithm(from_tuning["algorithms"], "rpi.alsc") + to_i = _index_of_algorithm(to_tuning["algorithms"], "rpi.alsc") + # Updating the dictionary in place. + to_tuning["algorithms"][to_i] = from_tuning["algorithms"][from_i] + + +def _index_of_algorithm(algorithms: list[dict], algorithm: str) -> int: + """Find the index of an algorithm's section in the tuning file.""" + for i, a in enumerate(algorithms): + if algorithm in a: + return i + raise ValueError(f"Algorithm {algorithm} is not available.") + + +def _as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]: + """Flatten array, round, and then convert to list.""" + return np.reshape(array, -1).round(round_to).tolist() From f24272bc7f12457b85bc810776a3679a148b37a6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Sep 2025 23:54:17 +0100 Subject: [PATCH 3/9] Set sensor_model as kwarg to SteamingPicameraThing. From this load sensor information. --- .../picamera2/test_tuning.py | 4 +- .../things/camera/picamera.py | 72 +++++--- .../camera/picamera_recalibrate_utils.py | 157 ++++++++++++------ .../camera/picamera_tuning_file_utils.py | 16 +- 4 files changed, 164 insertions(+), 85 deletions(-) diff --git a/hardware-specific-tests/picamera2/test_tuning.py b/hardware-specific-tests/picamera2/test_tuning.py index 5932af6f..b193131d 100644 --- a/hardware-specific-tests/picamera2/test_tuning.py +++ b/hardware-specific-tests/picamera2/test_tuning.py @@ -18,7 +18,7 @@ MODEL = Picamera2.global_camera_info()[0]["Model"] def generate_bad_tuning(): """Return a tuning file with an invalid version number to force an error when loaded.""" - default_tuning = tf_utils.load_default_tuning() + default_tuning = tf_utils.load_default_tuning("imx219") bad_tuning = default_tuning.copy() bad_tuning["version"] = 999 return bad_tuning @@ -55,7 +55,7 @@ def _test_bad_tuning_after_good_tuning(configure: bool = False): PiCamera2 behaviour does not expect the tuning file to be reloaded. """ bad_tuning = generate_bad_tuning() - default_tuning = tf_utils.load_default_tuning() + default_tuning = tf_utils.load_default_tuning("imx219") print_tuning() print("opening camera with default tuning") with Picamera2(tuning=default_tuning) as cam: diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index a6096372..8d00b007 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -47,6 +47,15 @@ from . import picamera_tuning_file_utils as tf_utils from . import BaseCamera, ArrayModel +SUPPORTED_SENSOR_INFO = { + "imx219": recalibrate_utils.IMX219_SENSOR_INFO, + "imx477": recalibrate_utils.IMX477_SENSOR_INFO, +} + + +class PicameraModelError(RuntimeError): + """There is a problem Picamera sensor model set by the configuration.""" + class MissingCalibrationError(RuntimeError): """Picamera tuning file is missing or doesn't contain the requested algorithm.""" @@ -130,24 +139,30 @@ class StreamingPiCamera2(BaseCamera): generalisation. """ - def __init__(self, camera_num: int = 0) -> None: + def __init__(self, camera_num: int = 0, sensor_model: str = "imx219") -> None: """Initialise the camera with the given camera number. This makes no connection to the camera (except to get the default tuning file). :param camera_num: The number of the camera. This should generally be left as 0 as most Raspberry Pi boards only support 1 camera. + :param sensor_model: The sensor model of the image sensor on this picamera. """ super().__init__() self._setting_save_in_progress = False - self.camera_num = camera_num - self.camera_configs: dict[str, dict] = {} + self._camera_num = camera_num + self._sensor_model = sensor_model + if sensor_model not in SUPPORTED_SENSOR_INFO: + raise PicameraModelError( + f"The sensor model {sensor_model} is not supported." + ) + self._sensor_info = SUPPORTED_SENSOR_INFO[sensor_model] self._picamera_lock = None self._picamera = None - logging.info("Starting & reconfiguring camera to populate sensor_modes.") - with Picamera2(camera_num=self.camera_num) as cam: - self.default_tuning = tf_utils.load_default_tuning(cam) - logging.info("Done reading sensor modes & default tuning.") + + # Load the tuning file for the specified sensor mode. + self.default_tuning = tf_utils.load_default_tuning(sensor_model) + # Set tuning to default tuning. This will be overwritten when the Thing is # connects to the server if tuning is saved to disk. try: @@ -356,11 +371,18 @@ class StreamingPiCamera2(BaseCamera): ) from e return None - def _initialise_picamera(self) -> None: + def _initialise_picamera(self, check_sensor_model: bool = False) -> None: """Acquire the picamera device and store it as ``self._picamera``. This duplicates logic in ``Picamera2.__init__`` to provide a tuning file that will be read when the camera system initialises. + + :param check_sensor_model: Set to true to check the sensor model is the + expected sensor model. This is used on ``__enter__`` to confirm that the + real camera matches the expected camera. + + :raises PicameraModelError: If check_sensor_model is True and the real + camera sensor model doesn't match the expected sensor model. """ if self._picamera_lock is not None: # Don't close the camera if it's in use @@ -383,9 +405,16 @@ class StreamingPiCamera2(BaseCamera): logging.info("Creating new Picamera2 object") # Specify tuning file otherwise it will be overwritten with None. self._picamera = Picamera2( - camera_num=self.camera_num, + camera_num=self._camera_num, tuning=self.tuning, ) + if check_sensor_model: + hw_sensor_model = self._picamera.camera_properties["Model"] + if hw_sensor_model != self._sensor_model: + raise PicameraModelError( + f"Wrong Picamera model. Expecting {self._sensor_model}, but " + f"found {hw_sensor_model}." + ) self._picamera_lock = RLock() def __enter__(self) -> None: @@ -394,7 +423,7 @@ class StreamingPiCamera2(BaseCamera): This opens the picamera connection, initialises the camera, sets the sensor_modes property, and then starts the streams. """ - self._initialise_picamera() + self._initialise_picamera(check_sensor_model=True) # Sensor modes is a cached property read it once after initialising the camera _modes = self.sensor_modes self.start_streaming() @@ -531,7 +560,7 @@ class StreamingPiCamera2(BaseCamera): logging.info("Stopped MJPEG stream.") # Adding a sleep to prevent camera getting confused by rapid commands - time.sleep(0.2) + time.sleep(self._sensor_info.short_pause) @lt.thing_action def discard_frames(self) -> None: @@ -549,7 +578,7 @@ class StreamingPiCamera2(BaseCamera): logging.debug("Reconfiguring camera for full resolution capture") cam.configure(cam.create_still_configuration(sensor=self._sensor_mode)) cam.start() - time.sleep(0.2) + time.sleep(self._sensor_info.short_pause) yield cam def capture_image( @@ -648,7 +677,7 @@ class StreamingPiCamera2(BaseCamera): @lt.thing_action def auto_expose_from_minimum( self, - target_white_level: int = 3000, + target_white_level: Optional[int] = None, percentile: float = 99.9, ) -> None: """Adjust exposure until a the target white level is reached. @@ -656,17 +685,21 @@ class StreamingPiCamera2(BaseCamera): Starting from the minimum exposure, gradually increase exposure until the image reaches the specified white level. - :param target_white_level: The target 10bit white level. 10-bit data has a - theoretical maximum of 1023, but with black level correction the true - maximum is about 950. Default is 700 as this is approximately 70% - saturated. + :param target_white_level: Raw target white level, this should be an integer + within the range set by the bit-depth of the camera sensor (10-bit for + PiCamera v2, 12 Bit for Picamera HQ. If None the default will be used for + the current sensor. This is approximately 70% saturated. :param percentile: The percentile to use instead of maximum. Default 99.9. When calculating the brightest pixel, a percentile is used rather than the maximum in order to be robust to a small number of noisy/bright pixels. """ + if target_white_level is None: + target_white_level = self._sensor_info.default_target_white_level + with self._streaming_picamera(pause_stream=True) as cam: recalibrate_utils.adjust_shutter_and_gain_from_raw( cam, + self._sensor_info, target_white_level=target_white_level, percentile=percentile, ) @@ -693,6 +726,7 @@ class StreamingPiCamera2(BaseCamera): lst: LensShading = self.lens_shading_tables recalibrate_utils.adjust_white_balance_from_raw( cam, + self._sensor_info, percentile=99, luminance=lst.luminance, Cr=lst.Cr, @@ -702,7 +736,7 @@ class StreamingPiCamera2(BaseCamera): ) else: recalibrate_utils.adjust_white_balance_from_raw( - cam, percentile=99, method=method + cam, self._sensor_info, percentile=99, method=method ) @lt.thing_action @@ -719,7 +753,7 @@ class StreamingPiCamera2(BaseCamera): # the standard mathematical terms for: # luminance (L), red-difference chroma (Cr), and blue-difference chroma # (Cb). - L, Cr, Cb = recalibrate_utils.lst_from_camera(cam) # noqa: N806 + L, Cr, Cb = recalibrate_utils.lst_from_camera(cam, self._sensor_info) # noqa: N806 tf_utils.set_static_lst(self.tuning, L, Cr, Cb) self._initialise_picamera() diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index 837ba4a7..dc425443 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -22,10 +22,15 @@ reliable. The three steps above can be accomplished by: .. code-block:: python picamera = picamera2.Picamera2() + sensor_info = IMX219_SENSOR_INFO - adjust_shutter_and_gain_from_raw(picamera) - adjust_white_balance_from_raw(picamera) - lst = lst_from_camera(picamera) + adjust_shutter_and_gain_from_raw( + picamera, + sensor_info, + target_white_level=sensor_info.default_target_white_level, + ) + adjust_white_balance_from_raw(picamera, sensor_info) + lst = lst_from_camera(picamera, sensor_info) picamera.lens_shading_table = lst """ @@ -48,29 +53,54 @@ from picamera2 import Picamera2 import picamera2 +class SensorInfo(BaseModel): + """Information about the sensor used for calibration and property setting.""" + + unpacked_pixel_format: str + """The format of the unpacked pixels.""" + + bit_depth: int + """The bit depth of each pixel.""" + + blacklevel: int + """The sensor black level.""" + + default_target_white_level: int + """The default target white level during exposure setting.""" + + short_pause: float + """The time to pause for actions that update quickly.""" + + long_pause: float + """Time to pause for actions that are known to update slowly.""" + + +IMX219_SENSOR_INFO = SensorInfo( + unpacked_pixel_format="SBGGR10", + bit_depth=10, + blacklevel=64, + default_target_white_level=700, + short_pause=0.2, + long_pause=0.5, +) + +IMX477_SENSOR_INFO = SensorInfo( + unpacked_pixel_format="SBGGR12", + bit_depth=12, + blacklevel=256, + default_target_white_level=2800, + short_pause=0.2, + long_pause=0.5, +) + + LensShadingTables = tuple[np.ndarray, np.ndarray, np.ndarray] -def set_minimum_exposure(camera: Picamera2) -> None: - """Enable manual exposure, with low gain and shutter speed. - - We set exposure mode to manual, analog and digital gain - to 1, and shutter speed to the minimum (8us for Pi Camera v2) - - Note ISO is left at auto, because this is needed for the gains - to be set correctly. - """ - # Disable Automatic exposure and gain algorithm (AeEnable), and set analogue - # gain and exposure time. - # Setting the shutter speed to 1us will result in it being set - # to the minimum possible, which is ~8us for PiCamera v2 - camera.set_controls({"AeEnable": False, "AnalogueGain": 1, "ExposureTime": 1}) - time.sleep(1) - - def adjust_shutter_and_gain_from_raw( camera: Picamera2, - target_white_level: int = 3000, + sensor_info: SensorInfo, + target_white_level: int, max_iterations: int = 20, tolerance: float = 0.05, percentile: float = 99.9, @@ -81,9 +111,12 @@ def adjust_shutter_and_gain_from_raw( are not affected by white balance or digital gain. :param camera: A Picamera2 object. - :param target_white_level: The raw, 10-bit value we aim for. The brightest pixels - should be approximately this bright. Maximum possible is about 900, 700 is - reasonable. + :param target_white_level: The raw value we aim for, the raw value of the brightest + pixels should be approximately this bright. The value to set depends on the + sensor bit depth. We recommend values of 700 for 10-bit sensors and 2800 for + 12-bit sensors. This is about 70% of saturated once the blacklevel is + subtracted. The maximum possible value depends on the sensor bit depth, the + sensor blackleve, the tolerance argument. :param max_iterations: We will terminate once we perform this many iterations, whether or not we converge. More than 10 shouldn't happen. :param tolerance: How close to the target value we consider "done". Expressed as a @@ -94,18 +127,20 @@ def adjust_shutter_and_gain_from_raw( than just ``np.max()``. """ - # TODO: read black level and bit depth from camera? - if target_white_level * (tolerance + 1) >= 3850: + max_level = 2**sensor_info.bit_depth - 1 - sensor_info.blacklevel + if target_white_level * (tolerance + 1) >= max_level: raise ValueError( "The target level is too high - a saturated image would be " "considered successful. target_white_level * (tolerance + 1) " - "must be less than 3850." + f"must be less than {max_level}." ) - config = camera.create_still_configuration(raw={"format": "SBGGR12"}) + config = camera.create_still_configuration( + raw={"format": sensor_info.unpacked_pixel_format} + ) camera.configure(config) camera.start() - set_minimum_exposure(camera) + _set_minimum_exposure(camera, sensor_info) # We start with very low exposure settings and work up # until either the brightness is high enough, or we can't increase the @@ -122,7 +157,7 @@ def adjust_shutter_and_gain_from_raw( new_time = int(test.exposure_time * min(target_white_level / test.level, 8)) camera.controls.ExposureTime = new_time camera.controls.AeEnable = False - time.sleep(1) + time.sleep(sensor_info.long_pause) # Check whether the shutter speed is still going up - if not, we've hit a maximum if camera.capture_metadata()["ExposureTime"] == test.exposure_time: @@ -140,7 +175,7 @@ def adjust_shutter_and_gain_from_raw( camera.controls.AnalogueGain = test.analog_gain * min( target_white_level / test.level, 2 ) - time.sleep(1) + time.sleep(sensor_info.long_pause) # Check the gain is still changing - if not, we have probably hit the maximum if camera.capture_metadata()["AnalogueGain"] == test.analog_gain: @@ -160,6 +195,7 @@ def adjust_shutter_and_gain_from_raw( def adjust_white_balance_from_raw( camera: Picamera2, + sensor_info: SensorInfo, percentile: float = 99, luminance: Optional[np.ndarray] = None, Cr: Optional[np.ndarray] = None, @@ -173,12 +209,13 @@ def adjust_white_balance_from_raw( We should probably have better logic to verify the channels really are BGGR... """ - config = camera.create_still_configuration(raw={"format": "SBGGR12"}) + config = camera.create_still_configuration( + raw={"format": sensor_info.unpacked_pixel_format} + ) camera.configure(config) camera.start() channels = _channels_from_bayer_array(camera.capture_array("raw")) - # TODO: read black level from camera rather than hard-coding 64 - blacklevel = 256 + if luminance is not None and Cr is not None and Cb is not None: # Reconstruct a low-resolution image from the lens shading tables # and use it to normalise the raw image, to compensate for @@ -205,10 +242,10 @@ def adjust_white_balance_from_raw( axis=(1, 2), ) # Subtract blacklevel before splitting into channels - blue, g1, g2, red = centre_means - blacklevel + blue, g1, g2, red = centre_means - sensor_info.blacklevel else: blue, g1, g2, red = ( - np.percentile(channels, percentile, axis=(1, 2)) - blacklevel + np.percentile(channels, percentile, axis=(1, 2)) - sensor_info.blacklevel ) green = (g1 + g2) / 2.0 new_awb_gains = (green / red, green / blue) @@ -225,16 +262,16 @@ def adjust_white_balance_from_raw( ) camera.controls.AwbEnable = False camera.controls.ColourGains = new_awb_gains - time.sleep(1) + time.sleep(sensor_info.long_pause) m = camera.capture_metadata() print(f"Camera confirms gains are now {m['ColourGains']}") return new_awb_gains -def lst_from_camera(camera: Picamera2) -> LensShadingTables: +def lst_from_camera(camera: Picamera2, sensor_info: SensorInfo) -> LensShadingTables: """Acquire a raw image and use it to calculate a lens shading table.""" - channels = _raw_channels_from_camera(camera) - return _lst_from_channels(channels) + channels = _raw_channels_from_camera(camera, sensor_info) + return _lst_from_channels(channels, sensor_info.blacklevel) def recreate_camera_manager() -> None: @@ -255,6 +292,23 @@ class _ExposureTest(BaseModel): analog_gain: float +def _set_minimum_exposure(camera: Picamera2, sensor_info: SensorInfo) -> None: + """Enable manual exposure, with low gain and shutter speed. + + Set exposure mode to manual, analog and digital gain to 1, and + shutter speed to the minimum (8us for Pi Camera v2) + + Note ISO is left at auto, because this is needed for the gains + to be set correctly. + """ + # Disable Automatic exposure and gain algorithm (AeEnable), and set analogue + # gain and exposure time. + # Setting the shutter speed to 1us will result in it being set + # to the minimum possible, which is ~8us for PiCamera v2 + camera.set_controls({"AeEnable": False, "AnalogueGain": 1, "ExposureTime": 1}) + time.sleep(sensor_info.long_pause) + + def _test_exposure_settings(camera: Picamera2, percentile: float) -> _ExposureTest: """Evaluate current exposure settings using a raw image. @@ -345,13 +399,8 @@ def _upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: return zoom(grids, zoom_factors, order=1)[:, : shape[0], : shape[1]] -def _downsampled_channels( - channels: np.ndarray, blacklevel: int = 256 -) -> list[np.ndarray]: - """Generate a downsampled, un-normalised image from which to calculate the LST. - - TODO: blacklevel probably ought to be determined from the camera... - """ +def _downsampled_channels(channels: np.ndarray, blacklevel: int) -> list[np.ndarray]: + """Generate a downsampled, un-normalised image from which to calculate the LST.""" channel_shape = np.array(channels.shape[1:]) lst_shape = np.array([12, 16]) step = np.ceil(channel_shape / lst_shape).astype(int) @@ -366,12 +415,12 @@ def _downsampled_channels( ) -def _lst_from_channels(channels: np.ndarray) -> LensShadingTables: +def _lst_from_channels(channels: np.ndarray, blacklevel: int) -> LensShadingTables: """Given the 4 Bayer colour channels from a white image, generate a LST. Internally, is just calls ``_downsampled_channels`` and ``_lst_from_grids``. """ - grids = _downsampled_channels(channels) + grids = _downsampled_channels(channels, blacklevel) return _lst_from_grids(grids) @@ -415,15 +464,17 @@ def _grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarr return np.stack([B, G, G, R], axis=0) -def _raw_channels_from_camera(camera: Picamera2) -> LensShadingTables: +def _raw_channels_from_camera( + camera: Picamera2, sensor_info: SensorInfo +) -> LensShadingTables: """Acquire a raw image and return a 4xNxM array of the colour channels.""" if camera.started: camera.stop_recording() # We will acquire a raw image with unpacked pixels, which is what the # format below requests. Bit depth and Bayer order may be overwritten. - # TODO: don't assume 10-bit - the high quality camera uses 12. - # TODO: what's the best mode to use here? - config = camera.create_still_configuration(raw={"format": "SBGGR12"}) + config = camera.create_still_configuration( + raw={"format": sensor_info.unpacked_pixel_format} + ) camera.configure(config) camera.start() raw_image = camera.capture_array("raw") diff --git a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py index 95df25b1..00e5a0d3 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py @@ -8,20 +8,14 @@ from picamera2 import Picamera2 import numpy as np -def load_default_tuning(cam: Picamera2) -> dict: +def load_default_tuning(sensor_model: str) -> dict: """Load the default tuning file for the camera. - This will open and close the camera to determine its model. If you are - using a model that's supported by ``picamera2`` it should have a tuning - file built in. If not, this will probably crash with an error. - - Error handling for unsupported cameras is not something we are likely - to test in the short term. + This will loat the tuning file based on the specified sensor model. """ - cp = cam.camera_properties - fname = f"{cp['Model']}.json" + fname = f"{sensor_model}.json" try: - return cam.load_tuning_file(fname) + return Picamera2.load_tuning_file(fname) except RuntimeError: tuning_dir = "/usr/share/libcamera/ipa/raspberrypi" # from picamera2 v0.3.9 @@ -29,7 +23,7 @@ def load_default_tuning(cam: Picamera2) -> dict: # odd - as that's where the files currently are on a default # Raspbian image. This may need updating if the files have moved # in future updates to the system libcamera package - return cam.load_tuning_file(fname, dir=tuning_dir) + return Picamera2.load_tuning_file(fname, dir=tuning_dir) def set_static_lst( From 283a64b94acbc5623cae8bd5b69ec7c63b37cf1e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Sep 2025 23:58:33 +0100 Subject: [PATCH 4/9] Add kwarg to picamera Thing in configuration file. --- ofm_config_full.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ofm_config_full.json b/ofm_config_full.json index 7f4c4077..28adf496 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -1,6 +1,11 @@ { "things": { - "/camera/": "openflexure_microscope_server.things.camera.picamera:StreamingPiCamera2", + "/camera/": { + "class": "openflexure_microscope_server.things.camera.picamera:StreamingPiCamera2", + "kwargs": { + "sensor_model": "imx219" + } + }, "/stage/": "openflexure_microscope_server.things.stage.sangaboard:SangaboardThing", "/autofocus/": "openflexure_microscope_server.things.autofocus:AutofocusThing", "/camera_stage_mapping/": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper", From 2ba8f3d6fb09ec3cf11fce9132a04e5ea8f8ee83 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 18 Sep 2025 00:00:59 +0100 Subject: [PATCH 5/9] Update picamera_tests.py to hash picamera_tuning_file_utils.py --- picamera_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/picamera_tests.py b/picamera_tests.py index a1bd38d0..892491eb 100755 --- a/picamera_tests.py +++ b/picamera_tests.py @@ -21,6 +21,7 @@ HASHED_FILES = [ os.path.join(CAM_DIR, "__init__.py"), os.path.join(CAM_DIR, "picamera.py"), os.path.join(CAM_DIR, "picamera_recalibrate_utils.py"), + os.path.join(CAM_DIR, "picamera_tuning_file_utils.py"), ] From ca66234379154dbc134d6be5d0e5ecfe5f565780 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 18 Sep 2025 10:25:05 +0000 Subject: [PATCH 6/9] Apply suggestions from code review of branch reorganise-picamera-tuning-and-recal Co-authored-by: Joe Knapper --- .../things/camera/picamera_recalibrate_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index dc425443..50d6c3ed 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -90,7 +90,7 @@ IMX477_SENSOR_INFO = SensorInfo( blacklevel=256, default_target_white_level=2800, short_pause=0.2, - long_pause=0.5, + long_pause=1.0, ) @@ -116,7 +116,7 @@ def adjust_shutter_and_gain_from_raw( sensor bit depth. We recommend values of 700 for 10-bit sensors and 2800 for 12-bit sensors. This is about 70% of saturated once the blacklevel is subtracted. The maximum possible value depends on the sensor bit depth, the - sensor blackleve, the tolerance argument. + sensor blacklevel and the tolerance argument. :param max_iterations: We will terminate once we perform this many iterations, whether or not we converge. More than 10 shouldn't happen. :param tolerance: How close to the target value we consider "done". Expressed as a @@ -127,6 +127,7 @@ def adjust_shutter_and_gain_from_raw( than just ``np.max()``. """ + # Calculate the maximum possible pixel value once blacklevel is subtracted. max_level = 2**sensor_info.bit_depth - 1 - sensor_info.blacklevel if target_white_level * (tolerance + 1) >= max_level: raise ValueError( From e89ad935357a3c6629b520fa7c09d94d08ec80d5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 18 Sep 2025 12:11:15 +0100 Subject: [PATCH 7/9] Specify the camera board not the sensor model during configuration. --- ofm_config_full.json | 2 +- .../things/camera/picamera.py | 30 +++++++++++-------- .../camera/picamera_recalibrate_utils.py | 5 ++++ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/ofm_config_full.json b/ofm_config_full.json index 28adf496..60b85a66 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -3,7 +3,7 @@ "/camera/": { "class": "openflexure_microscope_server.things.camera.picamera:StreamingPiCamera2", "kwargs": { - "sensor_model": "imx219" + "camera_board": "picamera_v2" } }, "/stage/": "openflexure_microscope_server.things.stage.sangaboard:SangaboardThing", diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 8d00b007..be01b337 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -47,9 +47,9 @@ from . import picamera_tuning_file_utils as tf_utils from . import BaseCamera, ArrayModel -SUPPORTED_SENSOR_INFO = { - "imx219": recalibrate_utils.IMX219_SENSOR_INFO, - "imx477": recalibrate_utils.IMX477_SENSOR_INFO, +SUPPORTED_CAMS_SENSOR_INFO = { + "picamera_v2": recalibrate_utils.IMX219_SENSOR_INFO, + "picamera_hq": recalibrate_utils.IMX477_SENSOR_INFO, } @@ -139,29 +139,33 @@ class StreamingPiCamera2(BaseCamera): generalisation. """ - def __init__(self, camera_num: int = 0, sensor_model: str = "imx219") -> None: + def __init__(self, camera_num: int = 0, camera_board: str = "picamera_v2") -> None: """Initialise the camera with the given camera number. This makes no connection to the camera (except to get the default tuning file). :param camera_num: The number of the camera. This should generally be left as 0 as most Raspberry Pi boards only support 1 camera. - :param sensor_model: The sensor model of the image sensor on this picamera. + :param camera_board: The camera board used. Supported options are "picamera_v2" + and "picamera_hq". """ super().__init__() self._setting_save_in_progress = False self._camera_num = camera_num - self._sensor_model = sensor_model - if sensor_model not in SUPPORTED_SENSOR_INFO: + self._camera_board = camera_board + if camera_board not in SUPPORTED_CAMS_SENSOR_INFO: raise PicameraModelError( - f"The sensor model {sensor_model} is not supported." + f"The camera_board {camera_board} is not supported. Supported boards " + f"are {SUPPORTED_CAMS_SENSOR_INFO.keys()}." ) - self._sensor_info = SUPPORTED_SENSOR_INFO[sensor_model] + self._sensor_info = SUPPORTED_CAMS_SENSOR_INFO[camera_board] self._picamera_lock = None self._picamera = None # Load the tuning file for the specified sensor mode. - self.default_tuning = tf_utils.load_default_tuning(sensor_model) + self.default_tuning = tf_utils.load_default_tuning( + self._sensor_info.sensor_model + ) # Set tuning to default tuning. This will be overwritten when the Thing is # connects to the server if tuning is saved to disk. @@ -410,10 +414,10 @@ class StreamingPiCamera2(BaseCamera): ) if check_sensor_model: hw_sensor_model = self._picamera.camera_properties["Model"] - if hw_sensor_model != self._sensor_model: + if hw_sensor_model != self._sensor_info.sensor_model: raise PicameraModelError( - f"Wrong Picamera model. Expecting {self._sensor_model}, but " - f"found {hw_sensor_model}." + f"Wrong Picamera model. Expecting {self._sensor_info.sensor_model}, " + f"but found {hw_sensor_model}." ) self._picamera_lock = RLock() diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index 50d6c3ed..5f38ee6e 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -56,6 +56,9 @@ import picamera2 class SensorInfo(BaseModel): """Information about the sensor used for calibration and property setting.""" + sensor_model: str + """The model of the sensor, as specified by the Picamera2 library.""" + unpacked_pixel_format: str """The format of the unpacked pixels.""" @@ -76,6 +79,7 @@ class SensorInfo(BaseModel): IMX219_SENSOR_INFO = SensorInfo( + sensor_model="imx219", unpacked_pixel_format="SBGGR10", bit_depth=10, blacklevel=64, @@ -85,6 +89,7 @@ IMX219_SENSOR_INFO = SensorInfo( ) IMX477_SENSOR_INFO = SensorInfo( + sensor_model="imx477", unpacked_pixel_format="SBGGR12", bit_depth=12, blacklevel=256, From 9c20c182306a228e6d0af6f718f04e746fafeb40 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 18 Sep 2025 12:22:56 +0100 Subject: [PATCH 8/9] Remove mock for loading tuning file during test as it no longer needs to talk to hardware. --- tests/test_cameras.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 06d282bf..27de7bd4 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -27,10 +27,7 @@ def mock_picam_thing(mocker): "picamera2.outputs": mocker.Mock(), }, ) - mocker.patch( - "openflexure_microscope_server.things.camera.picamera.recalibrate_utils.load_default_tuning", - return_value={"mock": "tuning"}, - ) + from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2 return StreamingPiCamera2() From 82f9753c02d468a70acb11c9da5dbabf146a0434 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 18 Sep 2025 13:07:29 +0100 Subject: [PATCH 9/9] Update picamera test results --- picamera_coverage.zip | Bin 53913 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 42e05dcf8d0d54139173979bdde923dba95d674c..d20a51c0def86625914bd1a2b3f2e3a8ee75de8e 100644 GIT binary patch delta 1639 zcmbQalzG}RW}yIYW)=|!5ICD*6n!pSHFTqptO29(W&;Bj0W(Q1Q3n2V{8RZu_%-<6 z@~z>Es;V z&nP@uz{i$RXtJM=6Qkhdi9S}W0xS%Tyf7t`fBHzU@G~?$Od|4*1_2psVfyjacgju+m88Rn7^irNI<;OGmv#;7@A3t6ePG*Kwu>N{KZdMK! zhDLQTFVB~K^4|;m((xswd6{|X@oAYksqv*HnK{LJ1(kv9=;}5f_seB8ljIU(;6Kmz zj`td`8P9*ND6Yv|V!Q?X(|C^ZhjRDut>ug2W9L4_U4Of1oa22&? zu7(KnF^Wv)kFjMGp6nar#3(d*LW~uwpesuw@8rN3gUSDW1Q`V;3&xtU@LMvMgLLpF zmX_qF_MU7EnOOSxR9IKn|Gn=Hqd>iXt3*`3(F&`9Jbc<8S7V<~z&R&X>R0 zQ6QYJUW|iSd;Mu{wG251VHO7mMi+KgM$SeKb|z5T|6zW6TkgM~?vEH4 z7?`d9R2*V%Xnex{=F#_iobKhMR^=C5cXlSKw+h0?czMs7A% zM$RTNHYSFD@3$w&8goDRl=8#afO(E1gZ#OIUF+)CGl;)^dGVhd??1c0@3&k};LVXp z=6Ud+wPD})UCxf%*&6OMy=P$1cu@cUD{FfD{A~}fGcYjxzrny@YQi94#;{>NFT;O+ z9u9K`gI$dbCJoXI|JW|@g51o;z{bE}(9oH2o`Hpdfq{*ISwcXKm4%VhkCmxD%|L?Z zjp+@}x7xga7}rQMawMFWGBDV{z`(%4z{K$Ck{}BUBc}?>DxTZha&NO7*pNGeIg)wu zg8nEqCKg6cJ|-qsMix#^J|-@Z4*pXN{J;6X@W17M#($sx2LDC=Q=1(HHt`#*WmUHn`5H}J3KU&gdU~HIdW?+(P zVPcwUW@(aQU|P>(eeC|M~e85pFRCK{v|o0*syBpVo*ni-pzn3)+E8yTA!C!3q4 zB_<^%CL5V1D(NUF=_Tivr4}Wor|K1CCMV{m7A1mAFfmUxNi#4_F-$cxHa0glwKO+K zPD@HkOG!&LG%+$tH8wFYl}br9uvDrI@MdHZVMeVlCJS7$-~&}2NZ}E7$(F715-61d E07Ti!tpET3 delta 1263 zcmbQXjCtl#W}yIYW)=|!5Lnz|7+oDwIAx=dtO29hW&;Bj0TT(X3E@oaX`0ES`8C8SZ=Bi@1}x6}X;qt>Mbp>?mNs#pWcz!qCV$ z+0jR7@&+&V$t7N5EaJ=zrBDH3MzP8A{;FW94_lLpex_FR`>FKP^AGv{7 z1q`_+yG1#(NLVnJPCgi;Jb8nc5R14Kb0w6+KKX+eFQeGxe^HK%qLUq>?HNTTH$+>r z2-`B(PktC@IQfG&C##SXOCvj&r|-kdA_&o~JvrNlhgHCprIB~?!#IPCN>XRSFS#CZam#Zkm&ezPq|C9eB|4IHlzNdWC_?kC63gq(D3$n8? za;mVb;<>#o_cqIc4Y@Ozec4zUIUA+enHU%t4(#`2;CEn{F_%G(>HZWs1_l}&4_~GENEk6ILyXiC(ZDWpNGSo!C+S- zgGqxl!#}nQrl9O2!63oFaDYK=ku9?T0|SEu15biOBn!x6M$AnAzMGxcq+{$nk!|wL zeor4}7Di4!CMGo|7Di4#R;Kzi0|}ltrZ+g>^42h{k!IvbI4@;juz`VrfrEjG;ngKp zMix#^J|->(1_pNiiwyj~`QP$C8BDQm)>^=&u-Rn6AASkctbZj{dcjmi28OAOlRYk+)=0HTOfpI}N;5aMv`n?M zFt$iCF)=hSN=&p&O|!5tPc=47HB2$Hw3vM1g3aXei<0#wCTT`ymT6`N29}2Arb&s3 z$tI@B1}2GSi6&_VhQ?+_Mka|CDaOf4Itog9$@yieMTzODdIg!uiMgpoiAq)qN|q_6 z28O9-<_4w~hG~|j=E*6BmgW|QNl8g5M#<($h8E^2mZ|0z21>O7-i%Bl%qYbc