diff --git a/.gitignore b/.gitignore index 6d1962c3..661ad967 100644 --- a/.gitignore +++ b/.gitignore @@ -89,5 +89,8 @@ openflexure_settings/ /tests/utilities/*.pstats /tests/utilities/*.png +# Files created by simulator +openflexure/ + # js version file /webapp/src/version.js diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 86300792..3d928164 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -2,31 +2,35 @@ ## Summary -(Summarize the bug encountered concisely) + ## Configuration + -I'm using: - -**Camera:** (E.g. Raspberry Pi camera v2) - -**Motor controller:** (E.g. Sangaboard, or Arduino Nano) +* **Sever version**: v2 or v3 +* **Release/Branch**: e.g. v3.0.0-alpha1 +* **Hardware Configuration:** + * Raspberry Pi Camera v2 + * Sangaboard v0.5 ## Steps to reproduce -(How one can reproduce the issue - this is very important) + ## Relevant logs and/or screenshots -To access your microscope log, either: + + ## Additional details -(Anything else you think might be relevant to mention) + -/label ~bug +/label ~bug diff --git a/.gitlab/issue_templates/Feature request.md b/.gitlab/issue_templates/Feature request.md index b3230205..5b5f999f 100644 --- a/.gitlab/issue_templates/Feature request.md +++ b/.gitlab/issue_templates/Feature request.md @@ -2,18 +2,18 @@ ## Summary -(Summarize the bug encountered concisely) + -## Configuration - -I'm using: - -**Camera:** (E.g. Raspberry Pi camera v2) - -**Motor controller:** (E.g. Sangaboard, or Arduino Nano) ## Additional details -(Anything else you think might be relevant to mention) + + + +/label ~feature diff --git a/hardware-specific-tests/picamera2/test_acquisition.py b/hardware-specific-tests/picamera2/test_acquisition.py new file mode 100644 index 00000000..3d8ef5c0 --- /dev/null +++ b/hardware-specific-tests/picamera2/test_acquisition.py @@ -0,0 +1,54 @@ +from fastapi.testclient import TestClient +from PIL import Image +import numpy as np +from pytest import fixture + +from labthings_fastapi.server import ThingServer +from labthings_fastapi.client import ThingClient + +from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2 + + +@fixture(scope="module") +def client(): + """ + A pytest fixture that initialises a test client for the StreamingPiCamera2 Thing. + This fixture sets up a ThingServer, registers a StreamingPiCamera2 instance at the + "/camera/" endpoint, and provides a ThingClient for interacting with it during tests. + """ + server = ThingServer() + server.add_thing(StreamingPiCamera2(), "/camera/") + with TestClient(server.app) as test_client: + client = ThingClient.from_url("/camera/", client=test_client) + yield client + + +def test_calibration(client): + """ + Check that full auto calibrate completes without an exception + """ + client.full_auto_calibrate() + + +def test_jpeg_and_array(client): + """ + Check that grabbing a jpeg from the stream results in the same size + image as a array capture or a jpeg capture. + """ + # Grab a jpeg from the stream + blob = client.grab_jpeg() + mjpeg_frame = Image.open(blob.open()) + assert mjpeg_frame + + # Capture a jpeg + blob = client.capture_jpeg(resolution="main") + jpeg_capture = Image.open(blob.open()) + assert jpeg_capture + + # Capture an array + arrlist = client.capture_array(stream_name="main") + array_main = np.array(arrlist) + + # Verify image sizes are the same + assert mjpeg_frame.size == jpeg_capture.size + assert array_main.shape[1::-1] == jpeg_capture.size diff --git a/hardware-specific-tests/picamera2/test_exposure_time_drift.py b/hardware-specific-tests/picamera2/test_exposure_time_drift.py new file mode 100644 index 00000000..4e712e5e --- /dev/null +++ b/hardware-specific-tests/picamera2/test_exposure_time_drift.py @@ -0,0 +1,71 @@ +""" +Check exposure times do not drift. + +This can get very tedious. Recommend running pytest with -s option +to monitor progress. +""" + +import logging +import time + +from fastapi.testclient import TestClient + +from labthings_fastapi.server import ThingServer +from labthings_fastapi.client import ThingClient + +from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2 + +logging.basicConfig(level=logging.DEBUG) + + +def _test_exposure_time_drift(desired_time): + """ + Capture 10 full resolution images and check that the exposure time remains constant + + This confirms that automatic exposure time adjustment is fully turned off + """ + cam = StreamingPiCamera2() + server = ThingServer() + server.add_thing(cam, "/camera/") + + with TestClient(server.app) as test_client: + client = ThingClient.from_url("/camera/", client=test_client) + exposure_tol = cam.persistent_control_tolerances["ExposureTime"] + client.exposure_time = desired_time + print(f"Setting desired time of {desired_time}") + time.sleep(0.5) + pre_capture_et = client.exposure_time + print(f"Pre-capture the time is set to {pre_capture_et}") + # Check exp is set correctly within known tolerance + assert abs(pre_capture_et - desired_time) < exposure_tol + + for i in range(10): + client.capture_jpeg(resolution="full") + if i == 0: + # Exposure can update on first capture, due to frame rate restrictions + first_et = client.exposure_time + assert abs(first_et - pre_capture_et) < exposure_tol + else: + frame_et = client.exposure_time + print(f"Frame {i} captured with exposure time {frame_et}") + # Check no further drift in value + assert first_et == frame_et + + # Set the exposure time to the value it already is. To check it doesn't shift + print(f"Setting exposure time to {frame_et} to check it doesn't change") + client.exposure_time = frame_et + time.sleep(0.5) + # Check before and after capture + assert client.exposure_time == frame_et + client.capture_jpeg(resolution="full") + assert client.exposure_time == frame_et + print("Exposure time didn't change!!") + print(f"End of test for exposure target {desired_time}") + + +def test_exposure_time_drift(): + """ + Performs the exposure time test for a range of exposure time values. + """ + for desired_time in [100, 1000, 10000]: + _test_exposure_time_drift(desired_time) diff --git a/hardware-specific-tests/picamera2/test_sensor_mode.py b/hardware-specific-tests/picamera2/test_sensor_mode.py new file mode 100644 index 00000000..fbc575d6 --- /dev/null +++ b/hardware-specific-tests/picamera2/test_sensor_mode.py @@ -0,0 +1,30 @@ +import logging + +from fastapi.testclient import TestClient +import numpy as np + +from labthings_fastapi.server import ThingServer +from labthings_fastapi.client import ThingClient + +from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2 + +logging.basicConfig(level=logging.DEBUG) + + +def test_sensor_mode(): + """ + Test capturing raw arrays in two different sensor modes + """ + cam = StreamingPiCamera2() + server = ThingServer() + server.add_thing(cam, "/camera/") + + with TestClient(server.app) as test_client: + client = ThingClient.from_url("/camera/", client=test_client) + for size in [(3280, 2464), (1640, 1232)]: + client.sensor_mode = {"output_size": size, "bit_depth": 10} + arr = np.array(client.capture_array(stream_name="raw")) + # Check that the array dimensions match the requested image size. + # Note: Numpy array shape is (y,x), but the sensor is set with (x,y) + # hence the need to compare index 0 with index 1. + assert arr.shape[0] == size[1] diff --git a/hardware-specific-tests/picamera2/test_tuning.py b/hardware-specific-tests/picamera2/test_tuning.py new file mode 100644 index 00000000..a6fd9b76 --- /dev/null +++ b/hardware-specific-tests/picamera2/test_tuning.py @@ -0,0 +1,119 @@ +""" +Tests that check that a tuning file can be reloaded +""" + +import os + +import pytest +from picamera2 import Picamera2 + +from openflexure_microscope_server.things.camera import ( + picamera_recalibrate_utils as recalibrate_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() + bad_tuning = default_tuning.copy() + bad_tuning["version"] = 999 + return bad_tuning + + +def print_tuning(read_file: bool = False): + """ + Print the path of the default tuning file from the the environment variable. + + :param read_file: Boolean, set true to also print the file contents. + + This is useful for debugging. As pytest suppresses the printing by default the + -s option is needed when running pytest to see this. + """ + key = "LIBCAMERA_RPI_TUNING_FILE" + if key in os.environ: + print(f"Tuning file environment variable: {os.environ[key]}") + if read_file: + with open(os.environ[key], "r") as f: + print(f.read()) + else: + print("Tuning file environment variable not set") + + +def _test_bad_tuning_after_good_tuning(configure: bool = False): + """ + Load the default tuning file into the camera, re-load with a broken tuning file, + check it errors. Finally check the default tuning file will load again afterwards. + + :param configure: Boolean, set true to configure the camera on initial loading + + This test checks that: + recalibrate_utils.recreate_camera_manager() is working as expected. As the default + PiCamera2 behaviour does not expect the tuning file to be reloaded. + """ + bad_tuning = generate_bad_tuning() + default_tuning = load_default_tuning() + print_tuning() + print("opening camera with default tuning") + with Picamera2(tuning=default_tuning) as cam: + print_tuning() + if configure: + cam.configure(cam.create_preview_configuration()) + del cam + recalibrate_utils.recreate_camera_manager() + print(f"Opening camera with bad tuning - ['version'] = {bad_tuning['version']}") + with pytest.raises(IndexError): + # The bad version should cause a problem + cam = Picamera2(tuning=bad_tuning) + print_tuning() + print("Success (not expected)!") + del cam + recalibrate_utils.recreate_camera_manager() + with Picamera2(tuning=default_tuning) as cam: + # Reload the camera with working tuning, or it will stop responding + # and fail future tests + pass + del cam + + +# Note: The tests below are marked with +# @pytest.mark.filterwarnings("ignore: Exception ignored") +# as the picamera will throw an exception ignored warning when deleting +# the picamera object. This is expected + +# The 2 pairs of repeated identical tests exist because pytest only +# starts once, and so by running the test more than one time checks +# that the camera can be initialised multiple times without restarting +# python. + + +@pytest.mark.filterwarnings("ignore: Exception ignored") +def test_bad_tuning_after_good_tuning_noconfigure(): + _test_bad_tuning_after_good_tuning(False) + + +@pytest.mark.filterwarnings("ignore: Exception ignored") +def test_bad_tuning_after_good_tuning_configure(): + _test_bad_tuning_after_good_tuning(True) + + +@pytest.mark.filterwarnings("ignore: Exception ignored") +def test_bad_tuning_after_good_tuning_noconfigure2(): + _test_bad_tuning_after_good_tuning(False) + + +@pytest.mark.filterwarnings("ignore: Exception ignored") +def test_bad_tuning_after_good_tuning_configure2(): + _test_bad_tuning_after_good_tuning(True) diff --git a/ofm_config_full.json b/ofm_config_full.json index a73e4e40..4e5beeee 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -1,6 +1,6 @@ { "things": { - "/camera/": "labthings_picamera2.thing:StreamingPiCamera2", + "/camera/": "openflexure_microscope_server.things.camera.picamera:StreamingPiCamera2", "/stage/": "labthings_sangaboard:SangaboardThing", "/auto_recentre_stage/": "openflexure_microscope_server.things.auto_recentre_stage:RecentringThing", "/autofocus/": "openflexure_microscope_server.things.autofocus:AutofocusThing", @@ -13,8 +13,7 @@ "scans_folder": "/var/openflexure/scans/" } }, - "/background_detect/": "openflexure_microscope_server.things.background_detect:BackgroundDetectThing", - "/capture/": "openflexure_microscope_server.things.capture:CaptureThing" + "/background_detect/": "openflexure_microscope_server.things.background_detect:BackgroundDetectThing" }, "settings_folder": "/var/openflexure/settings/", "log_folder": "/var/openflexure/logs/" diff --git a/pyproject.toml b/pyproject.toml index bfa7b2c5..55d346e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dev = [ "matplotlib~=3.10" ] pi = [ - "labthings-picamera2 == 0.0.2", + "picamera2~=0.3.27", ] [project.scripts] @@ -79,12 +79,13 @@ addopts = [ "--cov=openflexure_microscope_server", "--cov-report=term", "--cov-report=xml:coverage.xml", + "--cov-report=html:htmlcov", "--junitxml=report.xml", ] norecursedirs = [".git", "build", "node_modules"] - pythonpath = ["."] +testpaths = ["tests"] [tool.ruff.format] # Use native line endings for all files diff --git a/src/openflexure_microscope_server/server/__init__.py b/src/openflexure_microscope_server/server/__init__.py index 001b53bc..601c83ba 100644 --- a/src/openflexure_microscope_server/server/__init__.py +++ b/src/openflexure_microscope_server/server/__init__.py @@ -60,6 +60,7 @@ def serve_from_cli(argv: Optional[list[str]] = None): host=args.host, port=args.port, log_config=log_config, + timeout_graceful_shutdown=2, ) except BaseException as e: diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index c3359b9d..e3f6cc10 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -6,7 +6,6 @@ camera together to perform an autofocus routine. See repository root for licensing information. """ -from __future__ import annotations from contextlib import contextmanager import logging import time @@ -31,45 +30,64 @@ from .capture import RawCaptureDependency as CaptureDep class StackParams(BaseModel): - """Pydantic model for scan parameters""" + """Pydantic model for scan parameters - # These are generic settings that seem to apply well to a standard scan + :param stack_dz: The number of motor steps between images + :param images_to_save: The number of images to save to disk + :param min_images_to_test: The minimum number of images in the stack before, the + stack is evaluated for focus. As more images are captured evaluation of the focus + is always evaluated with the same number of images. i.e. if min_images_to_test=9, + then 9 images are captured, if the stack is not well focussed, a 10th image is + captured and images 2 to 10 are evaluated for focus + :param autofocus_dz: The number of steps in a full autofocus (if required) + + """ + + # These parameters must be set on initialisation + stack_dz: int + images_to_save: int + min_images_to_test: int + autofocus_dz: int + + # The following parameters have values that apply well to a standard scan # and are not altered by default when running a scan # time (in seconds) between moving and capturing an image settling_time: float = 0.3 + # distance (in steps) to overshoot a move and then undo, to account for backlash backlash_correction: int = 250 - # how many images can be appended to the stack after the predicted peak to test for focus - # before assuming the focus was passed, and restarting the stack + # how many images can be appended to the stack after the predicted peak to test + # for focus before assuming the focus was passed, and restarting the stack stack_height_limit: int = 15 # how far below (in factors of stack_dz) the estimated optimal starting position to - # begin the stack. Better to start slightly too low and require many images, rather than - # too high and needing to autofocus and restart the stack + # begin the stack. Better to start slightly too low and require many images, rather + # than too high and needing to autofocus and restart the stack img_undershoot: int = 5 - # These we expect to be overwritten by AutofocusThing properties - stack_dz: int = 50 - images_to_capture: int = 1 - images_to_test: int = 5 - autofocus_dz: int = 2000 - # Per pydantic docs, even with the @property applied before @computed_field, # mypy may throw a Decorated property not supported error (mypy issue #1362) - # To avoid this error message, add # type: ignore[prop-decorator] to the @computed_field line. + # To avoid this error message, add # type: ignore[prop-decorator] to the + # @computed_field line. @computed_field @property def stack_z_range(self) -> int: - """The range of the entire z stack, in steps""" - return self.stack_dz * (self.images_to_test - 1) + """The range of the z stack, in steps + + Note that this is the range of the minimum number of image captured, + which is also the range of the images sored in memory that can be + saved.""" + return self.stack_dz * (self.min_images_to_test - 1) @computed_field @property def steps_undershoot(self) -> int: - """The distance to deliberately undershoot the estimated optimal starting point""" + """ + The distance to deliberately undershoot the estimated optimal starting point + """ # Starting too low by "steps_undershoot" makes smart stacking faster. # Starting a stack too high requires it to move to the start, @@ -77,10 +95,24 @@ class StackParams(BaseModel): # requires extra +z movements and captures. return self.stack_dz * self.img_undershoot + @computed_field + @property + def max_images_to_test(self) -> int + """The maximum number of images that will be captured and tested in a stack + + This is 15 images more then the minimum number that are captured. + """ + return self.min_images_to_test + + +class SharpnessDataArrays(BaseModel): + jpeg_times: NDArray + jpeg_sizes: NDArray + stage_times: NDArray + stage_positions: list[dict[str, int]] + class JPEGSharpnessMonitor: - __globals__ = globals() # Required for FastAPI dependency - def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal): self.camera = camera self.stage = stage @@ -156,9 +188,7 @@ class JPEGSharpnessMonitor: stop = len(jpeg_times) logging.debug("changing stop to %s", (stop)) jpeg_times = jpeg_times[start:stop] - jpeg_zs: np.ndarray = np.interp( - jpeg_times, stage_times, stage_zs - ) # np.ndarray[float] + jpeg_zs: np.ndarray = np.interp(jpeg_times, stage_times, stage_zs) return jpeg_times, jpeg_zs, jpeg_sizes[start:stop] def sharpest_z_on_move(self, index: int) -> int: @@ -181,13 +211,6 @@ class JPEGSharpnessMonitor: SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()] -class SharpnessDataArrays(BaseModel): - jpeg_times: NDArray - jpeg_sizes: NDArray - stage_times: NDArray - stage_positions: list[dict[str, int]] - - class AutofocusThing(Thing): """The Thing concerned with combinations of z axis movements and the camera. @@ -298,24 +321,24 @@ class AutofocusThing(Thing): return heights.tolist(), sizes.tolist() @thing_property - def stack_images_to_capture(self) -> int: + def stack_images_to_save(self) -> int: """The number of images to capture and save in a stack Defaults to 1 unless you need to see either side of focus""" - return self.thing_settings.get("stack_images_to_capture", 1) + return self.thing_settings.get("stack_images_to_save", 1) - @stack_images_to_capture.setter - def stack_images_to_capture(self, value: int) -> None: - self.thing_settings["stack_images_to_capture"] = value + @stack_images_to_save.setter + def stack_images_to_save(self, value: int) -> None: + self.thing_settings["stack_images_to_save"] = value @thing_property - def stack_images_to_test(self) -> int: + def stack_min_images_to_test(self) -> int: """The number of images to test for successful focusing in a stack Defaults to 9, which balances reliability and speed""" - return self.thing_settings.get("stack_images_to_test", 9) + return self.thing_settings.get("stack_min_images_to_test", 9) - @stack_images_to_test.setter - def stack_images_to_test(self, value: int) -> None: - self.thing_settings["stack_images_to_test"] = value + @stack_min_images_to_test.setter + def stack_min_images_to_test(self, value: int) -> None: + self.thing_settings["stack_min_images_to_test"] = value @thing_property def stack_dz(self) -> int: @@ -336,10 +359,10 @@ class AutofocusThing(Thing): stage: Stage, logger: InvocationLogger, metadata_getter: GetThingStates, - capture: CaptureDep, sharpness_monitor: SharpnessMonitorDep, images_dir: str, autofocus_dz: int, + capture_resolution: tuple[int, int], ) -> None: """Run a smart stack, which captures images offset in z, testing whether the sharpest image is towards the centre of the stack. @@ -355,8 +378,8 @@ class AutofocusThing(Thing): # Set the variables to prevent changes from the GUI or other windows stack_parameters = StackParams( stack_dz=self.stack_dz, - images_to_capture=self.stack_images_to_capture, - images_to_test=self.stack_images_to_test, + images_to_save=self.stack_images_to_save, + min_images_to_test=self.stack_min_images_to_test, autofocus_dz=autofocus_dz, ) @@ -371,7 +394,6 @@ class AutofocusThing(Thing): stack_parameters, stage, cam, - capture, metadata_getter, ) @@ -393,11 +415,10 @@ class AutofocusThing(Thing): captures, stack_parameters, logger, - capture, ) # Return the z position of the sharpest image, for path planning and tracking - return heights[-stack_parameters.images_to_test :][sharpest_index] + return heights[-stack_parameters.min_images_to_test :][sharpest_index] def reset_stack( self, @@ -427,31 +448,26 @@ class AutofocusThing(Thing): captures: list[list], stack_parameters: StackParams, logger: InvocationLogger, - capture: CaptureDep, ) -> int: """Save the required captures to disk. Will save the sharpest image, and any images either side of focus. Arguments: - sharpest_index: the index of the sharpest image, within the "images_to_test" slice + sharpest_index: the index of the sharpest image, within the "min_images_to_test" slice captures: a list of captures, including file name, image data and metadata stack_parameters: a StackParams Pydantic model with stack settings variables logger and capture are Thing dependencies passed through from the calling action """ - # Find the range of images from the stack to capture - stack_extent = int((stack_parameters.images_to_capture - 1) / 2) - stack_range = range( - sharpest_index - stack_extent, sharpest_index + stack_extent + 1 - ) + # Loop through the range, saving each capture to disk for capture_index in stack_range: capture._save_capture( - jpeg_path=captures[-stack_parameters.images_to_test :][capture_index][ + jpeg_path=captures[-stack_parameters.min_images_to_test :][capture_index][ 0 ], - image=captures[-stack_parameters.images_to_test :][capture_index][1], - metadata=captures[-stack_parameters.images_to_test :][capture_index][2], + image=captures[-stack_parameters.min_images_to_test :][capture_index][1], + metadata=captures[-stack_parameters.min_images_to_test :][capture_index][2], logger=logger, ) return sharpest_index @@ -493,7 +509,7 @@ class AutofocusThing(Thing): # If the sharpest image isn't found within the 15 images above the estimated point, break # the loop and return "restart" - while len(captures) <= stack_parameters.images_to_test + 15: + while len(captures) <= stack_parameters.min_images_to_test + 15: time.sleep(stack_parameters.settling_time) # Append a new image to the stack @@ -509,14 +525,14 @@ class AutofocusThing(Thing): ) # If the number of images is enough to test, test them - if len(captures) >= stack_parameters.images_to_test: + if len(captures) >= stack_parameters.min_images_to_test: stack_result = self.check_stack_result( - sharpnesses[-stack_parameters.images_to_test :] + sharpnesses[-stack_parameters.min_images_to_test :] ) if stack_result == "success": sharpest_index = np.argmax( - sharpnesses[-stack_parameters.images_to_test :] + sharpnesses[-stack_parameters.min_images_to_test :] ) return "success", heights, captures, sharpest_index @@ -567,18 +583,18 @@ class AutofocusThing(Thing): """Check the stack settings are appropriate, and raise an error if not Arguments: - images_to_test: number of images in the stack to test for focus - images_to_capture: number of images to be captured around the focused image + min_images_to_test: number of images in the stack to test for focus + images_to_save: number of images to be captured around the focused image """ - if stack_parameters.images_to_test < stack_parameters.images_to_capture: + if stack_parameters.min_images_to_test < stack_parameters.images_to_save: raise RuntimeError( "Can't capture more images than are tested. Please increase number to test, or decrease number to capture" ) - if stack_parameters.images_to_test % 2 == 0: + if stack_parameters.min_images_to_test % 2 == 0: raise RuntimeError("Images to test should be odd") if ( - stack_parameters.images_to_test <= 0 - or stack_parameters.images_to_capture <= 0 + stack_parameters.min_images_to_test <= 0 + or stack_parameters.images_to_save <= 0 ): raise RuntimeError("Stack parameters need to be at least 1") diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 8d3fab23..14376e0f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -7,8 +7,12 @@ See repository root for licensing information. """ from __future__ import annotations -import logging -from typing import Literal, Protocol, runtime_checkable +from typing import Literal, Optional, Tuple +import json + +from pydantic import RootModel +from PIL import Image +import piexif from labthings_fastapi.thing import Thing from labthings_fastapi.decorators import thing_action, thing_property @@ -16,6 +20,7 @@ from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency +from labthings_fastapi.dependencies.invocation import InvocationLogger from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.outputs.blob import Blob from labthings_fastapi.types.numpy import NDArray @@ -25,160 +30,243 @@ class JPEGBlob(Blob): media_type: str = "image/jpeg" -@runtime_checkable -class CameraProtocol(Protocol): - """A Thing representing a camera""" +class PNGBlob(Blob): + """A class representing a PNG image as a LabThings FastAPI Blob""" - def __enter__(self) -> None: ... + media_type: str = "image/png" - def __exit__(self, _exc_type, _exc_value, _traceback) -> None: ... - @property - def stream_active(self) -> bool: - "Whether the MJPEG stream is active" - ... +class ArrayModel(RootModel): + """A model for an array""" - def snap_image(self) -> NDArray: - """Acquire one image from the camera.""" - ... + root: NDArray - def capture_array( - self, - stream_name: Literal["main", "lores", "raw"] = "main", - ) -> NDArray: ... - def capture_jpeg( - self, - metadata_getter: GetThingStates, - resolution: Literal["lores", "main", "full"] = "main", - ) -> JPEGBlob: - """Acquire one image from the camera and return as a JPEG blob""" - ... +class CaptureError(RuntimeError): + """An error trying to capture from a CameraThing""" - def grab_jpeg( - self, - portal: BlockingPortal, - stream_name: Literal["main", "lores"] = "main", - ) -> JPEGBlob: - """Acquire one image from the preview stream and return as an array - This differs from `capture_jpeg` in that it does not pause the MJPEG - preview stream. Instead, we simply return the next frame from that - stream (either "main" for the preview stream, or "lores" for the low - resolution preview). No metadata is returned. - """ - ... - - def grab_jpeg_size( - self, - portal: BlockingPortal, - stream_name: Literal["main", "lores"] = "main", - ) -> int: - """Acquire one image from the preview stream and return its size""" - ... +class NoImageInMemoryError(RuntimeError): + """An error called if no image in in memory when an method is called to use that image""" class BaseCamera(Thing): - """A Thing representing a camera - - This is a concrete base class for `Thing`s implementing the `CameraProtocol`. - It provides the stream descriptors and actions to grab from the stream. - """ + """The base class for all cameras. All cameras must directly inherit from this class""" + _memory_image: Optional[Image] = None + _memory_metadata: Optional[dict] = None mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor() - @thing_action - def snap_image(self) -> NDArray: - """Acquire one image from the camera. - - This action cannot run if the camera is in use by a background thread, for - example if a preview stream is running. - """ - return self.capture_array() - - @thing_action - def grab_jpeg( - self, - portal: BlockingPortal, - stream_name: Literal["main", "lores"] = "main", - ) -> JPEGBlob: - """Acquire one image from the preview stream and return as an array - - This differs from `capture_jpeg` in that it does not pause the MJPEG - preview stream. Instead, we simply return the next frame from that - stream (either "main" for the preview stream, or "lores" for the low - resolution preview). No metadata is returned. - """ - logging.info( - f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting" - ) - stream = ( - self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream - ) - frame = portal.call(stream.grab_frame) - logging.info( - f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame" - ) - return JPEGBlob.from_bytes(frame) - - @thing_action - def grab_jpeg_size( - self, - portal: BlockingPortal, - stream_name: Literal["main", "lores"] = "main", - ) -> int: - """Acquire one image from the preview stream and return its size""" - stream = ( - self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream - ) - return portal.call(stream.next_frame_size) - - -class CameraStub(BaseCamera): - """A stub for a camera, to allow dependencies - - - As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency - based on a protocol, because the protocol is not a Thing, and its - methods/properties are not decorated as Affordances. This stub class - is a workaround for that limitation, and should not be used directly. - - This stub class should be used for dependencies on the CameraProtocol. - """ - def __enter__(self) -> None: - raise NotImplementedError("Cameras must not inherit from CameraStub") + raise NotImplementedError("CameraThings must define their own __enter__ method") def __exit__(self, _exc_type, _exc_value, _traceback) -> None: - raise NotImplementedError("Cameras must not inherit from CameraStub") + raise NotImplementedError("CameraThings must define their own __exit__ method") + + @thing_property + def image_in_memory(self) -> bool: + """True if an image is in memory ready to be saved""" + return self._memory_image is not None and self._memory_metadata is not None + + @thing_action + def clear_image_memory(self) -> None: + """Clear any image in memory""" + self._memory_image = None + self._memory_metadata = None + + @thing_action + def start_streaming( + self, main_resolution: tuple[int, int], buffer_count: int + ) -> None: + """Start (or stop and restart) the camera with the given resolution + for the main stream, and buffer_count number of images in the buffer""" + raise NotImplementedError( + "CameraThings must define their own start_streaming method" + ) @thing_property def stream_active(self) -> bool: "Whether the MJPEG stream is active" - raise NotImplementedError("Cameras must not inherit from CameraStub") - - @thing_action - def snap_image(self) -> NDArray: - """Acquire one image from the camera.""" - raise NotImplementedError("Cameras must not inherit from CameraStub") + raise NotImplementedError( + "CameraThings must define their own stream_active method" + ) @thing_action def capture_array( self, - stream_name: Literal["main", "lores", "raw"] = "main", + stream_name: Literal["main", "lores", "raw", "full"] = "main", + wait: Optional[float] = 5, ) -> NDArray: - raise NotImplementedError("Cameras must not inherit from CameraStub") + raise NotImplementedError( + "CameraThings must define their own capture_array method" + ) @thing_action def capture_jpeg( self, metadata_getter: GetThingStates, resolution: Literal["lores", "main", "full"] = "main", + wait: Optional[float] = 5, ) -> JPEGBlob: """Acquire one image from the camera and return as a JPEG blob""" - raise NotImplementedError("Cameras must not inherit from CameraStub") + raise NotImplementedError( + "CameraThings must define their own capture_jpeg method" + ) + + @thing_action + def grab_jpeg( + self, + portal: BlockingPortal, + stream_name: Literal["main", "lores"] = "main", + ) -> JPEGBlob: + """Acquire one image from the preview stream and return as an array + + This differs from `capture_jpeg` in that it does not pause the MJPEG + preview stream. Instead, we simply return the next frame from that + stream (either "main" for the preview stream, or "lores" for the low + resolution preview). No metadata is returned. + """ + stream = ( + self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream + ) + frame = portal.call(stream.grab_frame) + return JPEGBlob.from_bytes(frame) + + @thing_action + def capture_image( + self, + stream_name: Literal["main", "lores", "raw"], + wait: Optional[float], + ) -> None: + """Capture a PIL image from stream stream_name with timeout wait""" + raise NotImplementedError( + "CameraThings must define their own capture_image method" + ) + + @thing_action + def capture_and_save( + self, + jpeg_path: str, + logger: InvocationLogger, + metadata_getter: GetThingStates, + save_resolution: Optional[Tuple[int, int]] = None, + ) -> None: + """Capture an image and save it to disk -CameraDependency = direct_thing_client_dependency(CameraStub, "/camera/") -RawCameraDependency = raw_thing_dependency(CameraProtocol) + save_resolution can be set to resize the image before saving. By default this is None + meaning that the image is saved at original resoltion. + """ + image, metadata = self._robust_image_capture( + metadata_getter, + logger=logger, + ) + + self._save_capture( + jpeg_path, + image, + metadata, + logger, + save_resolution, + ) + + @thing_action + def capture_to_memory( + self, + logger: InvocationLogger, + metadata_getter: GetThingStates, + ) -> None: + """ + Capture an image to memory. This can be saved later with `save_from_memory` + + Note that only one image is held in memory so this will overwrite any image + in memory. + """ + self._memory_image, self._memory_metadata = self._robust_image_capture( + metadata_getter, + logger=logger, + ) + + @thing_action + def save_from_memory( + self, + jpeg_path: str, + logger: InvocationLogger, + save_resolution: Optional[Tuple[int, int]] = None, + ) -> None: + """ + Save an image that has been captured to memory. + """ + if not self.image_in_memory: + raise NoImageInMemoryError("No image in memory to save.") + + self._save_capture( + jpeg_path=jpeg_path, + image=self._memory_image, + metadata=self._memory_metadata, + logger=logger, + save_resolution=save_resolution, + ) + self.clear_image_memory() + + def _robust_image_capture( + self, + metadata_getter: GetThingStates, + logger: InvocationLogger, + ) -> Image: + """Capture an image in memory and return it with metadata + CaptureError raised if the capture fails for any reason + returns tuple with PIL Image, and dict of metadata. + + This robust capturing method attempts to capture the image five times + each time with a 5 second timeout set. + """ + for capture_attempts in range(5): + try: + metadata = metadata_getter() + image = self.capture_image(stream_name="main", wait=5) + return image, metadata + except TimeoutError: + logger.warning( + f"Attempt {capture_attempts + 1} to capture image timed out. Do you have enough RAM?" + ) + raise CaptureError("An error occurred while capturing after 5 attempts") + + def _save_capture( + self, + jpeg_path: str, + image: Image, + metadata: dict, + logger: InvocationLogger, + save_resolution: Optional[Tuple[int, int]] = None, + ) -> None: + """Saving the captured image and metadata to disk + logger warning (via InvocationLogger) is raised if metadata is failed to be added + IOError is raised if the file cannot be saved + nothing is returned on success""" + if save_resolution is not None and image.size != save_resolution: + image = image.resize(save_resolution, Image.BOX) + try: + # Per PIL documentation, (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) + # there are two factors when saving a JPEG. subsampling affects the colour, quality the pixels + # subsampling = 0 disables subsampling of colour + # quality = 95 is the maximum recommended - above this, JPEG compression is disabled, file size increases and quality is barely or not affected + image.save(jpeg_path, quality=95, subsampling=0) + try: + # Load EXIF metadata from image so it can be added to. + exif_dict = piexif.load(jpeg_path) + exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( + metadata + ).encode("utf-8") + piexif.insert(piexif.dump(exif_dict), jpeg_path) + except: # noqa: E722 + # We need to capture any exception as there are many reasons metadata + # might not be added. We warn rather than log the error. + logger.warning(f"Failed to add metadata to {jpeg_path}") + except Exception as e: + raise IOError(f"An error occurred while saving {jpeg_path}") from e + + +CameraDependency = direct_thing_client_dependency(BaseCamera, "/camera/") +RawCameraDependency = raw_thing_dependency(BaseCamera) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index abb50252..f793f209 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -19,7 +19,6 @@ import piexif from labthings_fastapi.utilities import get_blocking_portal from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.dependencies.metadata import GetThingStates -from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.types.numpy import NDArray from . import BaseCamera, JPEGBlob @@ -53,9 +52,6 @@ class OpenCVCamera(BaseCamera): return self._capture_thread.is_alive() return False - mjpeg_stream = MJPEGStreamDescriptor() - lores_mjpeg_stream = MJPEGStreamDescriptor() - def _capture_frames(self): portal = get_blocking_portal(self) while self._capture_enabled: diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py new file mode 100644 index 00000000..56f8ff84 --- /dev/null +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -0,0 +1,919 @@ +""" +This SubModule interacts with a Raspberry Pi camera using the Picamera2 library. + +The Picamera2 library uses LibCamera as the underlying camera stack. This gives us +some control of the GPU pipeline for the image. + +The API documentation for PiCamera2 is unfortunately not in a standard auto-generated +website. For documentation of the PiCamera2 API there is a PDF called +"The Picamera2 Library" available at: +https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf + +For information on the algorithms used to tune/calibrate the Raspberry Pi Camera see +the guide called "Raspberry Pi Camera Algorithm and Tuning Guide" +Available at: +https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf +""" + +from __future__ import annotations +from datetime import datetime +import json +import logging +import os +import tempfile +import time +from tempfile import TemporaryDirectory + +from pydantic import BaseModel, BeforeValidator + +from labthings_fastapi.descriptors.property import PropertyDescriptor +from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.outputs.mjpeg_stream import MJPEGStream +from labthings_fastapi.utilities import get_blocking_portal +from labthings_fastapi.dependencies.metadata import GetThingStates +from labthings_fastapi.dependencies.blocking_portal import BlockingPortal +from typing import Annotated, Any, Iterator, Literal, Mapping, Optional +from contextlib import contextmanager +import piexif +from threading import RLock +import picamera2 +from picamera2 import Picamera2 +from picamera2.encoders import MJPEGEncoder +from picamera2.outputs import Output +import numpy as np +from . import picamera_recalibrate_utils as recalibrate_utils + +from . import BaseCamera, JPEGBlob, ArrayModel + + +class PicameraControl(PropertyDescriptor): + def __init__( + self, control_name: str, model: type = float, description: Optional[str] = None + ): + """A property descriptor controlling a picamera control""" + PropertyDescriptor.__init__( + self, model, observable=False, description=description + ) + self.control_name = control_name + + def _getter(self, obj: StreamingPiCamera2): + with obj.picamera() as cam: + return cam.capture_metadata()[self.control_name] + + def _setter(self, obj: StreamingPiCamera2, value: Any): + with obj.picamera() as cam: + cam.set_controls({self.control_name: value}) + + +class PicameraStreamOutput(Output): + """An Output class that sends frames to a stream""" + + def __init__(self, stream: MJPEGStream, portal: BlockingPortal): + """Create an output that puts frames in an MJPEGStream + + We need to pass the stream object, and also the blocking portal, because + new frame notifications happen in the anyio event loop and frames are + sent from a thread. The blocking portal enables thread-to-async + communication. + """ + Output.__init__(self) + self.stream = stream + self.portal = portal + + def outputframe( + self, frame, _keyframe=True, _timestamp=None, _packet=None, _audio=False + ): + """Add a frame to the stream's ringbuffer""" + self.stream.add_frame(frame, self.portal) + + +class SensorMode(BaseModel): + """ + A Pydantic model holding all the information about a specific + sensor mode as reported by the PiCamera. + """ + + unpacked: str + bit_depth: int + size: tuple[int, int] + fps: float + crop_limits: tuple[int, int, int, int] + exposure_limits: tuple[Optional[int], Optional[int], Optional[int]] + format: Annotated[str, BeforeValidator(repr)] + + +class SensorModeSelector(BaseModel): + """ + A Pydantic model holding the two values needed to select a PiCamera + Sensor mode. The output size and the bit depth. + + This is a Pydantic modell so that it can be saved to the disk. + """ + + output_size: tuple[int, int] + bit_depth: int + + +class LensShading(BaseModel): + """ + A Pydantic model holding the lens shading tables. + + PiCamera needs three numpy arrays for lens shading correction. Each array is + (12, 16) in size. The arrays are luminance, red-difference chroma (Cr), and + blue-difference chroma (Cb). + + This is a Pydantic modell so that it can be saved to the disk. + """ + + luminance: list[list[float]] + Cr: list[list[float]] + Cb: list[list[float]] + + +class StreamingPiCamera2(BaseCamera): + """A Thing that represents an OpenCV camera""" + + def __init__(self, camera_num: int = 0): + self.camera_num = camera_num + self.camera_configs: dict[str, dict] = {} + # Note persistent controls will be updated with settings, in __enter__. + self.persistent_controls = { + "AeEnable": False, + "AnalogueGain": 1.0, + "AwbEnable": False, + "Brightness": 0, + "ColourGains": (1, 1), + "Contrast": 1, + "ExposureTime": 0, + "Saturation": 1, + "Sharpness": 1, + } + self.persistent_control_tolerances = { + "ExposureTime": 30, + } + + def update_persistent_controls(self, discard_frames: int = 1): + """Update the persistent controls dict from the camera + + Query the camera and update the value of `persistent_controls` to + match the current state of the camera. + + There is a work-around here, that will suppress small updates. There + appears to be a bug in the camera code that causes a slight drift in + `ExposureTime` each time the camera is reinitialised: this can + add up over time, particularly if the camera is reconfigured many + times. To get around this, we look in `self.persistent_control_tolerances` + and only update `self.persistent_controls` if the change is greater than + this tolerance. + """ + with self.picamera() as cam: + for i in range(discard_frames): + # Discard frames, so data is fresh + cam.capture_metadata() + for key, value in cam.capture_metadata().items(): + if key not in self.persistent_controls: + # Skip keys that are not persistent controls + continue + if self._control_value_within_tolerance(key, value): + logging.debug( + "Ignoring a small change in persistent control %s from %s to " + "%s while updating persistent controls.", + key, + self.persistent_controls[key], + value, + ) + else: + self.persistent_controls[key] = value + self.thing_settings.update(self.persistent_controls) + + def _control_value_within_tolerance(self, key: str, value: float) -> bool: + """Return True if the updated value for a persistent control is within + tolerance of current value. This allows ignoring small changes + """ + if key not in self.persistent_control_tolerances: + # Not tolerance range set, so it is not within tolerance + return False + + difference = np.abs(self.persistent_controls[key] - value) + return difference < self.persistent_control_tolerances[key] + + def settings_to_persistent_controls(self): + """Update the persistent controls dict from the settings dict + + NB this must be called **after** self.thing_settings is initialised, + i.e. during or after `__enter__`. + """ + try: + pc = self.thing_settings["persistent_controls"] + except KeyError: + return # If there are no saved settings, use defaults + for k in self.persistent_controls: + try: + self.persistent_controls[k] = pc[k] + except KeyError: + pass # If controls are missing, leave at default + + stream_resolution = PropertyDescriptor( + tuple[int, int], + initial_value=(820, 616), + description="Resolution to use for the MJPEG stream", + ) + + mjpeg_bitrate = PropertyDescriptor( + Optional[int], + initial_value=100000000, + description="Bitrate for MJPEG stream (None for default)", + ) + + stream_active = PropertyDescriptor( + bool, + initial_value=False, + description="Whether the MJPEG stream is active", + observable=True, + readonly=True, + ) + + analogue_gain = PicameraControl("AnalogueGain", float) + colour_gains = PicameraControl("ColourGains", tuple[float, float]) + + exposure_time = PicameraControl( + "ExposureTime", int, description="The exposure time in microseconds" + ) + + @exposure_time.setter + def exposure_time(self, value: int): + """ + Custom setter for the above exposure_time PicameraControl. + + This is overriding the standard setter for a PicameraControl, as + the value needs to be adjusted before setting to behave as expected. + + See comment within the function for more detail. + """ + with self.picamera() as cam: + # Note: This set a value 1 higher than requested as picamera2 always sets + # a lower value than requested, even if the requested is allowed + cam.set_controls({"ExposureTime": value + 1}) + + _sensor_modes = None + + @thing_property + def sensor_modes(self) -> list[SensorMode]: + """All the available modes the current sensor supports""" + if not self._sensor_modes: + with self.picamera() as cam: + self._sensor_modes = cam.sensor_modes + return self._sensor_modes + + @thing_property + def sensor_mode(self) -> Optional[SensorModeSelector]: + """The intended sensor mode of the camera""" + return self.thing_settings["sensor_mode"] + + @sensor_mode.setter + def sensor_mode(self, new_mode: Optional[SensorModeSelector]): + """Change the sensor mode used""" + if isinstance(new_mode, SensorModeSelector): + new_mode = new_mode.model_dump() + with self.picamera(pause_stream=True): + self.thing_settings["sensor_mode"] = new_mode + + @thing_property + def sensor_resolution(self) -> tuple[int, int]: + """The native resolution of the camera's sensor""" + with self.picamera() as cam: + return cam.sensor_resolution + + tuning = PropertyDescriptor(Optional[dict], None, readonly=True) + + def settings_to_properties(self): + """Set the values of properties based on the settings dict""" + try: + props = self.thing_settings["properties"] + except KeyError: + return + for k, v in props.items(): + setattr(self, k, v) + + def properties_to_settings(self): + """Save certain properties to the settings dictionary""" + props = {} + for k in ["mjpeg_bitrate", "stream_resolution"]: + props[k] = getattr(self, k) + self.thing_settings["properties"] = props + + def initialise_tuning(self): + """Read the tuning from the settings, or load default tuning + + NB this relies on `self.thing_settings` and `self.default_tuning` + so will fail if it's run before those are populated in `__enter__`. + """ + if "tuning" in self.thing_settings: + self.tuning = self.thing_settings["tuning"].dict + else: + logging.info("Did not find tuning in settings, reading from camera...") + self.tuning = self.default_tuning + + def initialise_picamera(self): + """Acquire the picamera device and store it as `self._picamera`""" + if hasattr(self, "_picamera_lock"): + # Don't close the camera if it's in use + self._picamera_lock.acquire() + with tempfile.NamedTemporaryFile("w") as tuning_file: + # This duplicates logic in `Picamera2.__init__` to provide a tuning file + # that will be read when the camera system initialises. + # This is a necessary work-around until `picamera2` better supports + # reinitialisation of the camera with new tuning. + json.dump(self.tuning, tuning_file) + tuning_file.flush() # but leave it open as closing it will delete it + os.environ["LIBCAMERA_RPI_TUNING_FILE"] = tuning_file.name + # NB even though we've put the tuning file in the environment, we will + # need to specify the filename in the `Picamera2` initialiser as otherwise + # it will be overwritten with None. + if hasattr(self, "_picamera") and self._picamera: + print("Closing picamera object for reinitialisation") + logging.info( + "Camera object already exists, closing for reinitialisation" + ) + self._picamera.close() + print("closed, deleting picamera") + del self._picamera + recalibrate_utils.recreate_camera_manager() + print("[re]creating Picamera2 object") + self._picamera = picamera2.Picamera2( + camera_num=self.camera_num, + tuning=self.tuning, + ) + self._picamera_lock = RLock() + + def __enter__(self): + self.populate_default_tuning() + self.initialise_tuning() + self.initialise_picamera() + # populate sensor modes by reading the property + self.sensor_modes + self.settings_to_persistent_controls() + self.settings_to_properties() + self.start_streaming() + return self + + @contextmanager + def picamera(self, pause_stream=False) -> Iterator[Picamera2]: + """Return the underlying `Picamera2` instance, optionally pausing the stream. + + If pause_stream is True (default is False), we will stop the MJPEG stream + before yielding control of the camera, and restart afterwards. If you make + changes to the camera settings, these may be ignored when the stream is + restarted: you may nened to call `update_persistent_controls()` to ensure + your changes persist after the stream restarts. + """ + already_streaming = self.stream_active + with self._picamera_lock: + if pause_stream and already_streaming: + self.update_persistent_controls() + self.stop_streaming(stop_web_stream=False) + try: + yield self._picamera + finally: + if pause_stream and already_streaming: + self.start_streaming() + + def populate_default_tuning(self): + """Sensor modes are enumerated and stored, once, on start-up (`__enter__`). + + This opens and closes the camera - must be run before the camera is + initialised. + """ + 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) + logging.info("Done reading sensor modes & default tuning.") + + def __exit__(self, exc_type, exc_value, traceback): + # Allow key controls to persist across restarts + self.update_persistent_controls() + self.thing_settings["persistent_controls"] = self.persistent_controls + self.thing_settings["tuning"] = self.tuning + self.properties_to_settings() + self.thing_settings.write_to_file() + # Shut down the camera + self.stop_streaming() + with self.picamera() as cam: + cam.close() + del self._picamera + + @thing_action + def start_streaming( + self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6 + ) -> None: + """ + Start the MJPEG stream + + Sets the camera resolutions based on input parameters, and sets the low-res + resolution to (320, 240). Note: (320, 240) is a standard from the Pi Camera + manual. + + Create two streams: + - `lores_mjpeg_stream` for autofocus at low-res resolution + - `mjpeg_stream` for preview. This is the `main_resolution` if this is less + than (1280, 960), or the low-res resolution if above. This allows for + high resolution capture without streaming high resolution video. + + main_resolution: the resolution for the main configuration. Defaults to + (820, 616), 1/4 sensor size. + buffer_count: the number of frames to hold in the buffer. Higher uses more memory, + lower may cause dropped frames. Value must be between 1 and 8, Defaults to 6. + """ + + # Buffer count can't be negative, zero, or too high. + if buffer_count < 1 or buffer_count > 8: + # 8 is slightly arbitrary. 6 is the PiCamera default for video + # and the documentation only says that setting values higher gives + # diminishing returns, and that the true maximum is hardware dependent + raise ValueError( + f"Can't set a buffer count of {buffer_count}. " + "Buffer count must be an integer from 1-8" + ) + with self.picamera() as picam: + try: + if picam.started: + picam.stop() + picam.stop_encoder() # make sure there are no other encoders going + stream_config = picam.create_video_configuration( + main={"size": main_resolution}, + lores={"size": (320, 240), "format": "YUV420"}, + sensor=self.thing_settings.get("sensor_mode", None), + controls=self.persistent_controls, + ) + stream_config["buffer_count"] = buffer_count + picam.configure(stream_config) + logging.info("Starting picamera MJPEG stream...") + stream_name = "lores" if main_resolution[0] > 1280 else "main" + picam.start_recording( + MJPEGEncoder(self.mjpeg_bitrate), + PicameraStreamOutput( + self.mjpeg_stream, + get_blocking_portal(self), + ), + name=stream_name, + ) + picam.start_encoder( + MJPEGEncoder(100000000), + PicameraStreamOutput( + self.lores_mjpeg_stream, + get_blocking_portal(self), + ), + name="lores", + ) + except Exception as e: + logging.exception("Error while starting preview: {e}") + logging.exception(e) + else: + self.stream_active = True + logging.debug( + "Started MJPEG stream at %s on port %s", self.stream_resolution, 1 + ) + + @thing_action + def stop_streaming(self, stop_web_stream: bool = True) -> None: + """ + Stop the MJPEG stream + """ + with self.picamera() as picam: + try: + picam.stop_recording() # This should also stop the extra lores encoder + except Exception as e: + logging.info("Stopping recording failed") + logging.exception(e) + else: + self.stream_active = False + if stop_web_stream: + self.mjpeg_stream.stop() + self.lores_mjpeg_stream.stop() + logging.info("Stopped MJPEG stream.") + + # Adding a sleep to prevent camera getting confused by rapid commands + time.sleep(0.2) + + @thing_action + def capture_image( + self, + stream_name: Literal["main", "lores", "raw"] = "main", + wait: Optional[float] = 0.9, + ) -> None: + """Acquire one image from the camera. + + Return it as a PIL Image + + stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", "lores", "raw"]. Default = "main" + wait: (Optional, float) Set a timeout in seconds. + A TimeoutError is raised if this time is exceeded during capture. + Default = 0.9s, lower than the 1s timeout default in picamera yaml settings + """ + with self.picamera() as cam: + return cam.capture_image(stream_name, wait=wait) + + @thing_action + def capture_array( + self, + stream_name: Literal["main", "lores", "raw", "full"] = "main", + wait: Optional[float] = 0.9, + ) -> ArrayModel: + """Acquire one image from the camera and return as an array + + This function will produce a nested list containing an uncompressed RGB image. + It's likely to be highly inefficient - raw and/or uncompressed captures using + binary image formats will be added in due course. + + stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", "lores", "raw", "full"]. Default = "main" + wait: (Optional, float) Set a timeout in seconds. + A TimeoutError is raised if this time is exceeded during capture. + Default = 0.9s, lower than the 1s timeout default in picamera yaml settings + """ + + # This was slower than capture_image for our use case, but directly returning + # an image as an array is still a useful feature + if stream_name == "full": + with self.picamera(pause_stream=True) as picam2: + capture_config = picam2.create_still_configuration() + return picam2.switch_mode_and_capture_array(capture_config, wait=wait) + with self.picamera() as cam: + return cam.capture_array(stream_name, wait=wait) + + @thing_property + def camera_configuration(self) -> Mapping: + """The "configuration" dictionary of the picamera2 object + + The "configuration" sets the resolution and format of the camera's streams. + Together with the "tuning" it determines how the sensor is configured and + how the data is processed. + + Note that the configuration may be modified when taking still images, and + this property refers to whatever configuration is currently in force - + usually the one used for the preview stream. + """ + with self.picamera() as cam: + return cam.camera_configuration() + + @thing_action + def capture_jpeg( + self, + metadata_getter: GetThingStates, + resolution: Literal["lores", "main", "full"] = "main", + wait: Optional[float] = 0.9, + ) -> JPEGBlob: + """Acquire one image from the camera as a JPEG + + The JPEG will be acquired using `Picamera2.capture_file`. If the + `resolution` parameter is `main` or `lores`, it will be captured + from the main preview stream, or the low-res preview stream, + respectively. This means the camera won't be reconfigured, and + the stream will not pause (though it may miss one frame). + + If `full` resolution is requested, we will briefly pause the + MJPEG stream and reconfigure the camera to capture a full + resolution image. + + wait: (Optional, float) Set a timeout in seconds. + A TimeoutError is raised if this time is exceeded during capture. + Default = 0.9s, lower than the 1s timeout default in picamera yaml settings + + Note that this always uses the image processing pipeline - to + bypass this, you must use a raw capture. + """ + fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg") + folder = TemporaryDirectory() + path = os.path.join(folder.name, fname) + config = self.camera_configuration + # Low-res and main streams are running already - so we don't need + # to reconfigure for these + if resolution in ("lores", "main") and config[resolution]: + with self.picamera() as cam: + cam.capture_file(path, name=resolution, format="jpeg", wait=wait) + else: + if resolution != "full": + logging.warning( + f"There was no {resolution} stream, capturing full resolution" + ) + with self.picamera(pause_stream=True) as cam: + logging.info("Reconfiguring camera for full resolution capture") + cam.configure(cam.create_still_configuration()) + cam.start() + cam.options["quality"] = 95 + logging.info("capturing") + cam.capture_file(path, name="main", format="jpeg", wait=wait) + logging.info("done") + # After the file is written, add metadata about the current Things + exif_dict = piexif.load(path) + exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( + metadata_getter() + ).encode("utf-8") + piexif.insert(piexif.dump(exif_dict), path) + return JPEGBlob.from_temporary_directory(folder, fname) + + @thing_property + def capture_metadata(self) -> dict: + """Return the metadata from the camera""" + with self.picamera() as cam: + return cam.capture_metadata() + + @thing_action + def auto_expose_from_minimum( + self, + target_white_level: int = 700, + percentile: float = 99.9, + ): + """Adjust exposure until a the target white level is reached + + 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 maxiumum + is about 950. Default is 700 as 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. + """ + with self.picamera(pause_stream=True) as cam: + recalibrate_utils.adjust_shutter_and_gain_from_raw( + cam, + target_white_level=target_white_level, + percentile=percentile, + ) + self.update_persistent_controls() + + @thing_action + def calibrate_white_balance( + self, + method: Literal["percentile", "centre"] = "centre", + luminance_power: float = 1.0, + ): + """Correct the white balance of the image + + This calibration requires a neutral image, such that the 99th centile + of each colour channel should correspond to white. We calculate the + centiles and use this to set the colour gains. This is done on the raw + image with the lens shading correction applied, which should mean + that the image is uniform, rather than weighted towards the centre. + + If `method` is `"centre"`, we will correct the mean of the central 10% + of the image. + """ + with self.picamera(pause_stream=True) as cam: + if self.lens_shading_is_static: + lst: LensShading = self.lens_shading_tables + recalibrate_utils.adjust_white_balance_from_raw( + cam, + percentile=99, + luminance=lst.luminance, + Cr=lst.Cr, + Cb=lst.Cb, + luminance_power=luminance_power, + method=method, + ) + else: + recalibrate_utils.adjust_white_balance_from_raw( + cam, percentile=99, method=method + ) + self.update_persistent_controls() + + @thing_action + def calibrate_lens_shading(self) -> None: + """Take an image and use it for flat-field correction. + + This method requires an empty (i.e. bright) field of view. It will take + a raw image and effectively divide every subsequent image by the current + one. This uses the camera's "tuning" file to correct the preview and + the processed images. It should not affect raw images. + """ + with self.picamera(pause_stream=True) as cam: + # Suppress lint warning that L, Cr, and Cb are not lowercase, as these are + # 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 + recalibrate_utils.set_static_lst(self.tuning, L, Cr, Cb) + self.initialise_picamera() + + @thing_property + def colour_correction_matrix( + self, + ) -> tuple[float, float, float, float, float, float, float, float, float]: + """An alias for `colour_correction_matrix` to fit the micromanager API""" + return self.thing_settings.get( + "colour_correction_matrix", + tuple(recalibrate_utils.get_static_ccm(self.tuning)[0]["ccm"]), + ) + + @colour_correction_matrix.setter # type: ignore + def colour_correction_matrix(self, value) -> None: + self.thing_settings["colour_correction_matrix"] = value + self.calibrate_colour_correction(value) + + @thing_action + def reset_ccm(self): + """ + Overwrite the colour correction matrix in camera tuning with default values. + + These values are from the Raspberry Pi Camera Algorithm and Tuning Guide, page + 45. + """ + # This is flattened 3x3 matrix. See `calibrate_colour_correction` + col_corr_matrix = [ + 1.80439, + -0.73699, + -0.06739, + -0.36073, + 1.83327, + -0.47255, + -0.08378, + -0.56403, + 1.64781, + ] + self.colour_correction_matrix = col_corr_matrix + + @thing_action + def calibrate_colour_correction( + self, + col_corr_matrix: tuple[ + float, float, float, float, float, float, float, float, float + ], + ) -> None: + """Overwrite the colour correction matrix in camera tuning + + col_corr_matrix: This is a 9 value tuple used to specify the 3x3 + matrix that the GPU pipeline uses to convert from the camera R,G,B vector + to the standard R,G,B. + + See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45. + """ + with self.picamera(pause_stream=True): + recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix) + self.initialise_picamera() + + @thing_action + def set_static_green_equalisation(self, offset: int = 65535) -> None: + """Set the green equalisation to a static value. + + Green equalisation avoids the debayering algorithm becoming confused + by the two green channels having different values, which is a problem + when the chief ray angle isn't what the sensor was designed for, and + that's the case in e.g. a microscope using camera module v2. + + A value of 0 here does nothing, a value of 65535 is maximum correction. + """ + with self.picamera(pause_stream=True): + recalibrate_utils.set_static_geq(self.tuning, offset) + self.initialise_picamera() + + @thing_action + def full_auto_calibrate(self) -> None: + """Perform a full auto-calibration + + This function will call the other calibration actions in sequence: + + * `flat_lens_shading` to disable flat-field + * `auto_expose_from_minimum` + * `set_static_green_equalisation` to set geq offset to max + * `calibrate_lens_shading` + * `calibrate_white_balance` + """ + self.flat_lens_shading() + self.auto_expose_from_minimum() + self.set_static_green_equalisation() + self.calibrate_lens_shading() + self.calibrate_white_balance() + + @thing_action + def flat_lens_shading(self) -> None: + """Disable flat-field correction + + This method will set a completely flat lens shading table. It is not the + same as the default behaviour, which is to use an adaptive lens shading + table. + + This flat table is used to take an image wth no lens shading so that the + correct lens shading table can be calibrated. + """ + with self.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 + ) + self.initialise_picamera() + + @thing_property + def lens_shading_tables(self) -> Optional[LensShading]: + """The current lens shading (i.e. flat-field correction) + + Return the current lens shading correction, as three 2D lists each with + dimensions 16x12, if a static lens shading table is in use. + + Return None if: + - adaptive control is enabled + - multiple LSTs in use (for different colour temperatures), + """ + if not self.lens_shading_is_static: + return None + + # Note "alsc" is the Picamera2 term for "Automatic Lens Shading Correction" + alsc = Picamera2.find_tuning_algo(self.tuning, "rpi.alsc") + + # Check there is exactly 1 correction table for red-difference chroma (Cr) + # and blue-difference chroma (Cb) + if len(alsc["calibrations_Cr"]) != 1 or len(alsc["calibrations_Cb"]) != 1: + # If there is not exactly one table, then lens shading isn't static. + return None + + def reshape_lst(lin: list[float]) -> list[list[float]]: + """Reshape the 192 element list into a 2D 16x12 list""" + w, h = 16, 12 + return [lin[w * i : w * (i + 1)] for i in range(h)] + + return LensShading( + luminance=reshape_lst(alsc["luminance_lut"]), + Cr=reshape_lst(alsc["calibrations_Cr"][0]["table"]), + Cb=reshape_lst(alsc["calibrations_Cb"][0]["table"]), + ) + + @lens_shading_tables.setter + def lens_shading_tables(self, lst: LensShading) -> None: + """Set the lens shading tables""" + with self.picamera(pause_stream=True): + recalibrate_utils.set_static_lst( + self.tuning, + luminance=lst.luminance, + cr=lst.Cr, + cb=lst.Cb, + ) + self.initialise_picamera() + + def correct_colour_gains_for_lens_shading( + self, colour_gains: tuple[float, float] + ) -> tuple[float, float]: + """Correct white balance gains for the effect of lens shading + + The white balance algorithm we use assumes the brightest pixels + should be white, and that the only thing affecting the colour of + said pixels is the `colour_gains`. + + The lens shading correction is normalised such that the *minimum* + gain in the `Cr` and `Cb` channels is 1. The white balance + assumption above requires that the gain for the brightest pixels + is 1. The solution might be that, when calibrating, we note which + pixels are brightest (usually the centre) and explicitly use + the LST values for there. However, for now I will assume that we + need to normalise by the **maximum** of the `Cr` and `Cb` + channels, which is correct the majority of the time. + """ + if not self.lens_shading_is_static: + return colour_gains + lst = self.lens_shading_tables + # The Cr and Cb corrections are normalised to have a minimum of 1, + # but the white balance algorithm normalises the brightest pixels + # to be white, assuming the brightest pixels have equal gain from + # the LST. + gain_r, gain_b = colour_gains + return ( + float(gain_r / np.max(lst.Cr)), + float(gain_b / np.max(lst.Cb)), + ) + + @thing_action + def flat_lens_shading_chrominance(self) -> None: + """Disable flat-field correction + + This method will set the chrominance of the lens shading table to be + flat, i.e. we'll correct vignetting of intensity, but not any change in + colour across the image. + """ + with self.picamera(pause_stream=True): + alsc = Picamera2.find_tuning_algo(self.tuning, "rpi.alsc") + luminance = alsc["luminance_lut"] + flat = np.ones((12, 16)) + recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat) + self.initialise_picamera() + + @thing_action + def reset_lens_shading(self) -> None: + """Revert to default lens shading settings + + This method will restore the default "adaptive" lens shading method used + by the Raspberry Pi camera. + """ + with self.picamera(pause_stream=True): + recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) + self.initialise_picamera() + + @thing_property + def lens_shading_is_static(self) -> bool: + """Whether the lens shading is static + + This property is true if the lens shading correction has been set to use + a static table (i.e. the number of automatic correction iterations is zero). + 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) diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py new file mode 100644 index 00000000..cf51efe8 --- /dev/null +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -0,0 +1,565 @@ +""" +Functions to set up a Raspberry Pi Camera v2 for scientific use + +This module provides slower, simpler functions to set the +gain, exposure, and white balance of a Raspberry Pi camera, using +the `picamera2` Python library. It's mostly used by the OpenFlexure +Microscope, though it deliberately has no hard dependencies on +said software, so that it's useful on its own. + +There are three main calibration steps: + +* Setting exposure time and gain to get a reasonably bright + image. +* Fixing the white balance to get a neutral image +* Taking a uniform white image and using it to calibrate + the Lens Shading Table + +The most reliable way to do this, avoiding any issues relating +to "memory" or nonlinearities in the camera's image processing +pipeline, is to use raw images. This is quite slow, but very +reliable. The three steps above can be accomplished by: + +``` +picamera = picamera2.Picamera2() + +adjust_shutter_and_gain_from_raw(picamera) +adjust_white_balance_from_raw(picamera) +lst = lst_from_camera(picamera) +picamera.lens_shading_table = lst +``` +""" + +# Disable N806 & 803, which checks that all variables and args are lowercase. +# This is due to the number of matrix calculations and colour channel +# calculations that are clearer using the standard R, G, B, or L, Cr, Cb terms. +# ruff: noqa: N806 N803 + +from __future__ import annotations +import gc +import logging +import time +from typing import List, Literal, Optional, Tuple +from pydantic import BaseModel +import numpy as np +from scipy.ndimage import zoom + +from picamera2 import Picamera2 +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 + + 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 algoritm (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(0.5) + + +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 = 700, + max_iterations: int = 20, + tolerance: float = 0.05, + percentile: float = 99.9, +) -> float: + """Adjust exposure and analog gain based on raw images. + + This routine is slow but effective. It uses raw images, so we + are not affected by white balance or digital gain. + + + Arguments: + 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. + max_iterations: + We will terminate once we perform this many iterations, + whether or not we converge. More than 10 shouldn't happen. + tolerance: + How close to the target value we consider "done". Expressed + as a fraction of the ``target_white_level`` so 0.05 means + +/- 5% + percentile: + Rather then use the maximum value for each channel, we + calculate a percentile. This makes us robust to single + pixels that are bright/noisy. 99.9% still picks the top + of the brightness range, but seems much more reliable + than just ``np.max()``. + """ + # TODO: read black level and bit depth from camera? + if target_white_level * (tolerance + 1) >= 959: + 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." + ) + + config = camera.create_still_configuration(raw={"format": "SBGGR10"}) + camera.configure(config) + camera.start() + set_minimum_exposure(camera) + + # We start with very low exposure settings and work up + # until either the brightness is high enough, or we can't increase the + # shutter speed any more. + iterations = 0 + while iterations < max_iterations: + test = test_exposure_settings(camera, percentile) + if check_convergence(test, target_white_level, tolerance): + break + iterations += 1 + + # Adjust shutter speed so that the brightness approximates the target + # NB we put a maximum of 8 on this, to stop it increasing too quickly. + 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) + + # Check whether the shutter speed is still going up - if not, we've hit a maximum + if camera.capture_metadata()["ExposureTime"] == test.exposure_time: + logging.info(f"Shutter speed has maxed out at {test.exposure_time}") + break + + # 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): + break + iterations += 1 + + # Adjust gain to make the white level hit the target, again with a maximum + camera.controls.AnalogueGain = test.analog_gain * min( + target_white_level / test.level, 2 + ) + time.sleep(0.5) + + # Check the gain is still changing - if not, we have probably hit the maximum + if camera.capture_metadata()["AnalogueGain"] == test.analog_gain: + logging.info(f"Gain has maxed out at {test.analog_gain}") + break + + if check_convergence(test, target_white_level, tolerance): + logging.info(f"Brightness has converged to within {tolerance * 100:.0f}%.") + else: + logging.warning( + f"Failed to reach target brightness of {target_white_level}." + f"Brightness reached {test.level} after {iterations} iterations." + ) + + return test.level + + +def adjust_white_balance_from_raw( + camera: Picamera2, + percentile: float = 99, + luminance: Optional[np.ndarray] = None, + Cr: Optional[np.ndarray] = None, + Cb: Optional[np.ndarray] = None, + luminance_power: float = 1.0, + method: Literal["percentile", "centre"] = "centre", +) -> Tuple[float, float]: + """Adjust the white balance in a single shot, based on the raw image. + + NB if ``channels_from_raw_image`` is broken, this will go haywire. + We should probably have better logic to verify the channels really + are BGGR... + """ + config = camera.create_still_configuration(raw={"format": "SBGGR10"}) + 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 + 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) + channel_gains = 1 / grids + if channel_gains.shape[1:] != 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))}" + ) + channels = channels * channel_gains + logging.info(f"After gains, channel maxima are {np.max(channels, axis=(1, 2))}") + if method == "centre": + _, height, width = channels.shape + # Cut out the central 10% from 9/20 to 11/20... + low_y_range = 9 * height // 20 + hi_y_range = 11 * height // 20 + low_x_range = 9 * width // 20 + hi_x_range = 11 * width // 20 + # ... and then take the mean of each bayer channel. + centre_means = np.mean( + channels[:, low_y_range:hi_y_range, low_x_range:hi_x_range], + axis=(1, 2), + ) + # Subtract blacklevel before splitting into channels + blue, g1, g2, red = centre_means - blacklevel + else: + blue, g1, g2, red = ( + np.percentile(channels, percentile, axis=(1, 2)) - blacklevel + ) + green = (g1 + g2) / 2.0 + new_awb_gains = (green / red, green / blue) + if Cr is not None and Cb is not None: + # The LST algorithm normalises Cr and Cb by their minimum. + # The lens shading correction only ever boosts the red and blue values. + # Here, we decrease the gains by the minimum value of Cr and Cb. + new_awb_gains = (green / red * np.min(Cr), green / blue * np.min(Cb)) + + logging.info( + f"Raw white point is R: {red} G: {green} B: {blue}, " + f"setting AWB gains to ({new_awb_gains[0]:.2f}, " + f"{new_awb_gains[1]:.2f})." + ) + camera.controls.AwbEnable = False + camera.controls.ColourGains = new_awb_gains + time.sleep(0.2) + m = camera.capture_metadata() + print(f"Camera confirms gains are now {m['ColourGains']}") + return new_awb_gains + + +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) + channels_shape: Tuple[int, int, int] = ( + 4, + bayer_array.shape[0] // 2, + bayer_array.shape[1] // 2, + ) + channels: np.ndarray = np.zeros(channels_shape, dtype=bayer_array.dtype) + for i, offset in enumerate(bayer_pattern): + # We simplify life by dealing with only one channel at a time. + channels[i, :, :] = bayer_array[offset[0] :: 2, offset[1] :: 2] + + return channels + + +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 + https://git.linuxtv.org/libcamera.git/tree/utils/raspberrypi/ctt/ctt_alsc.py + for consistency. + """ + grid = [] + + # since left and bottom border will not necessarily have rectangles of + # dimension dx x dy, the final iteration has to be handled separately. + for i in range(11): + for j in range(15): + grid.append(np.mean(chan[dy * i : dy * (1 + i), dx * j : dx * (1 + j)])) + grid.append(np.mean(chan[dy * i : dy * (1 + i), 15 * dx :])) + for j in range(15): + grid.append(np.mean(chan[11 * dy :, dx * j : dx * (1 + j)])) + grid.append(np.mean(chan[11 * dy :, 15 * dx :])) + # return as np.array, ready for further manipulation + return np.reshape(np.array(grid), (12, 16)) + + +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` + """ + zoom_factors = [ + 1, + ] + list(np.ceil(np.array(shape) / np.array(grids.shape[1:]))) + return zoom(grids, zoom_factors, order=1)[:, : shape[0], : shape[1]] + + +def downsampled_channels(channels: np.ndarray, blacklevel=64) -> 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... + """ + channel_shape = np.array(channels.shape[1:]) + lst_shape = np.array([12, 16]) + step = np.ceil(channel_shape / lst_shape).astype(int) + return np.stack( + [ + get_16x12_grid( + channels[i, ...].astype(float) - blacklevel, step[1], step[0] + ) + for i in range(channels.shape[0]) + ], + axis=0, + ) + + +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`. + """ + grids = downsampled_channels(channels) + return lst_from_grids(grids) + + +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 + + The LST format has changed with `picamera2` and now uses a fixed resolution, + and is in luminance, Cr, Cb format. This function returns three ndarrays of + luminance, Cr, Cb, each with shape (12, 16). + """ + + # Calculated red, green, and blue channels from Bayer data + r: np.ndarray = grids[3, ...] + g: np.ndarray = np.mean(grids[1:3, ...], axis=0) + b: np.ndarray = grids[0, ...] + + # What we actually want to calculate is the gains needed to compensate for the + # lens shading - that's 1/lens_shading_table_float as we currently have it. + + # Minimum luminance gain is 1 + luminance_gains: np.ndarray = np.max(g) / g + + cr_gains: np.ndarray = g / r + cb_gains: np.ndarray = g / b + + return luminance_gains, cr_gains, cb_gains + + +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. + Also, note that the channels are BGGR, to be consistent with the + `channels_from_raw_image` function. This should probably change in the + future. + """ + G = 1 / np.array(lum) + R = G / np.array(Cr) + B = G / np.array(Cb) + 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 correcton. + + `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 correcton. + + `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 to always use green + equalisation that averages the green pixels in the red and blue rows. + + `tuning` will be updated in-place to set the geq offest to the given value. + The default 65535 is the maximum allowed value. This means + the brightness will always be below the threshold where averaging is used. + """ + + 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: + """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": "SBGGR10"}) + camera.configure(config) + camera.start() + raw_image = camera.capture_array("raw") + camera.stop() + # Now we need to calculate a lens shading table that would make this flat. + # raw_image is a 3D array, with full resolution and 3 colour channels. No + # de-mosaicing has been done, so 2/3 of the values are zero (3/4 for R and B + # 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() diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index ad0ad1c2..c27ebc20 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -22,12 +22,9 @@ from scipy.ndimage import gaussian_filter from labthings_fastapi.utilities import get_blocking_portal from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.dependencies.metadata import GetThingStates -from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor -from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.server import ThingServer -from pydantic import RootModel -from . import BaseCamera, JPEGBlob +from . import BaseCamera, JPEGBlob, ArrayModel from ..stage import StageProtocol as Stage # The ratio between "motor" steps and pixels @@ -35,12 +32,6 @@ from ..stage import StageProtocol as Stage RATIO = 0.2 -class ArrayModel(RootModel): - """A model for an array""" - - root: NDArray - - class SimulatedCamera(BaseCamera): """A Thing representing an OpenCV camera""" @@ -160,9 +151,6 @@ class SimulatedCamera(BaseCamera): return self._capture_thread.is_alive() return False - mjpeg_stream = MJPEGStreamDescriptor() - lores_mjpeg_stream = MJPEGStreamDescriptor() - def _capture_frames(self): portal = get_blocking_portal(self) while self._capture_enabled: diff --git a/src/openflexure_microscope_server/things/capture.py b/src/openflexure_microscope_server/things/capture.py deleted file mode 100644 index 896ce1bc..00000000 --- a/src/openflexure_microscope_server/things/capture.py +++ /dev/null @@ -1,66 +0,0 @@ -from PIL import Image -import piexif -import json -import numpy as np - -from labthings_fastapi.dependencies.metadata import GetThingStates -from labthings_fastapi.thing import Thing -from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency -from .camera import CameraDependency as CamDep -from labthings_fastapi.dependencies.invocation import ( - InvocationLogger, -) - - -class CaptureError(RuntimeError): - """An error trying to capture from Picamera""" - - -class CaptureThing(Thing): - """A temporary Thing to handle capturing to disk with associated metadata - Will be moved to the camera Thing or dependency in due course""" - - def _capture_image( - self, cam: CamDep, metadata_getter: GetThingStates - ) -> tuple[np.ndarray, dict]: - """Capture an image in memory and return it with metadata - CaptureError raised if the capture fails for any reason - returns tuple with numpy array of image data, and dict of metadata - """ - try: - metadata = metadata_getter() - image = cam.capture_array()[..., :3] - except Exception as e: - raise CaptureError("An error occurred while capturing") from e - return image, metadata - - def _save_capture( - self, - jpeg_path: str, - image: Image.Image, - metadata: dict, - logger: InvocationLogger, - ) -> None: - """Saving the captured image and metadata to disk - logger warning (via InvocationLogger) is raised if metadata is failed to be added - IOError is raised if the file cannot be saved - nothing is returned on success""" - try: - Image.fromarray(image.astype("uint8"), "RGB").save( - jpeg_path, quality=95, subsampling=0 - ) - try: - exif_dict = piexif.load(jpeg_path) - exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( - metadata - ).encode("utf-8") - piexif.insert(piexif.dump(exif_dict), jpeg_path) - except: # noqa: E722 - # We need to capture any exception as there are many reasons metadata - # might not be added. We warn rather than log the error. - logger.warning(f"Failed to add metadata to {jpeg_path}") - except Exception as e: - raise IOError(f"An error occurred while saving {jpeg_path}") from e - - -RawCaptureDependency = raw_thing_dependency(CaptureThing) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index b907004c..b29fc2f3 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -59,11 +59,11 @@ def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None: Raises: NotEnoughFreeSpaceError if the remaining storage is below min_space """ - du = shutil.disk_usage(path) - if du.free < min_space: + disk_usage = shutil.disk_usage(path) + if disk_usage.free < min_space: raise NotEnoughFreeSpaceError( "There is not enough free disk space to continue. " - f"(Required: {min_space}, {du})." + f"(Required: {min_space >> 20}MB, free: {disk_usage.free >> 20}MB)." ) @@ -90,6 +90,7 @@ SCAN_DATA_FILENAME = "scan_data.json" STITCHING_CMD = "openflexure-stitch" SCAN_ZERO_PAD_DIGITS = 4 +STITCHING_RESOLUTION = (820, 616) def _scan_running(method): @@ -374,28 +375,28 @@ class SmartScanThing(Thing): return (next_point[0], next_point[1], z_estimate) @_scan_running - def _take_test_image_to_calc_displacement(self, overlap): + def _calc_displacement_from_test_image(self, overlap: int) -> tuple[int, int]: """ Take a test image and use camera stage mapping to calculate x and y displacement + :param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means + that each image should overlap its nearest neighbour by 50%. + Return (dx, dy) - the x and y displacments in steps """ test_jpg = self._cam.grab_jpeg() test_image = np.array(Image.open(test_jpg.open())) - test_image_res = list(test_image.shape[:2]) + test_image_res = list(test_image.shape) csm_image_res = [int(i) for i in self._csm.image_resolution] - if test_image_res != csm_image_res: - raise RuntimeError( - "Cannot start scan as it is set up to capture with a resolution that " - "has not been mapped.\n" - f"Scan resolution: {test_image_res}\n" - f"camera-stage-mapping resolution {csm_image_res}." - ) + # If current stream width is different to csm calibration width, + # perform the conversion here + res_ratio = csm_image_res[0] / test_image_res[0] # get displacement matrix. note it is for (y, x) not (x, y) coordinates - csm_disp_matrix = self._csm.image_to_stage_displacement_matrix + csm_disp_matrix = np.array(self._csm.image_to_stage_displacement_matrix) + csm_disp_matrix *= res_ratio # Calculate displacements in image coordinates dx_img = test_image.shape[1] * (1 - overlap) @@ -419,7 +420,13 @@ class SmartScanThing(Thing): dataclass. """ overlap = self.overlap - dx, dy = self._take_test_image_to_calc_displacement(overlap) + dx, dy = self._calc_displacement_from_test_image(overlap) + stitch_resize = STITCHING_RESOLUTION[0] / self.capture_resolution[0] + + self._scan_logger.debug( + f"Resizing images when stitching by a factor of {stitch_resize}" + ) + self._scan_logger.info( f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}" ) @@ -446,6 +453,8 @@ class SmartScanThing(Thing): "start_time": time.strftime("%H_%M_%S-%d_%m_%Y"), "skip_background": self.skip_background, "stitch_automatically": self.stitch_automatically, + "stitch_resize": stitch_resize, + "capture_resolution": self.capture_resolution, } @_scan_running @@ -464,6 +473,7 @@ class SmartScanThing(Thing): "dy": self._scan_data["dy"], "start time": self._scan_data["start_time"], "skipping background": self._scan_data["skip_background"], + "capture resolution": self._scan_data["capture_resolution"], } scan_inputs_fname = os.path.join( @@ -533,6 +543,7 @@ class SmartScanThing(Thing): scan_successful = True try: + self._cam.start_streaming(main_resolution=(3280, 2464)) self._set_scan_data() self._save_scan_inputs_json() if self._scan_images_taken != 0: @@ -564,6 +575,8 @@ class SmartScanThing(Thing): ) raise e finally: + # Start streaming in the default resolution again as soon as possible + self._cam.start_streaming() if self._capture_thread: # If the capture thread had an error, we capture it here try: @@ -652,7 +665,19 @@ class SmartScanThing(Thing): ) route_planner.mark_location_visited( - current_pos_xyz, imaged=True, focused=True + current_pos_xyz, imaged=True, focused=focused + ) + + site_folder = os.path.join( + self._ongoing_scan_images_dir, + "stacks", + f"{new_pos_xyz[0]}_{new_pos_xyz[1]}", + ) + os.makedirs(site_folder, exist_ok=True) + self._autofocus.run_z_stack( + images_dir=self._ongoing_scan_images_dir, + stack_dir=site_folder, + capture_resolution=self._scan_data["capture_resolution"], ) # increment capure counter as thread has completed @@ -692,6 +717,7 @@ class SmartScanThing(Thing): self.stitch_scan( logger=self._scan_logger, scan_name=self._ongoing_scan_name, + stitch_resize=self._scan_data["stitch_resize"], overlap=self._scan_data["overlap"], ) self.promote_stitch_files(self._scan_logger) @@ -721,6 +747,16 @@ class SmartScanThing(Thing): raise HTTPException(404, "File not found") return FileResponse(path) + @thing_property + def capture_resolution(self) -> tuple[int, int]: + """A tuple of the image resolution to capture. Should be in a + 4:3 aspect ratio""" + return self.thing_settings.get("capture_resolution", ((1640, 1232))) + + @capture_resolution.setter + def capture_resolution(self, value: tuple[int, int]) -> None: + self.thing_settings["capture_resolution"] = value + @thing_property def max_range(self) -> int: """The maximum distance from the centre of the scan before we break in steps""" @@ -973,6 +1009,8 @@ class SmartScanThing(Thing): "preview_stitch", "--minimum_overlap", f"{min_overlap}", + "--resize", + f"{self._scan_data['stitch_resize']}", self._ongoing_scan_images_dir, ] ) @@ -1043,6 +1081,7 @@ class SmartScanThing(Thing): self, logger: InvocationLogger, scan_name: str, + stitch_resize: Optional[float] = None, overlap: float = 0.0, ) -> None: """Generate a stitched image based on stage position metadata @@ -1062,7 +1101,6 @@ class SmartScanThing(Thing): try: with open(json_fpath, "r", encoding="utf-8") as data_file: data_loaded = json.load(data_file) - logger.info(data_loaded) overlap = data_loaded["overlap"] except (json.decoder.JSONDecodeError, FileNotFoundError, TypeError): # As there is no schema or pydantic model this should handle @@ -1079,6 +1117,29 @@ class SmartScanThing(Thing): "Attempting stitch with overlap value of 0.1" ) overlap = 0.1 + + if stitch_resize is None: + try: + with open(json_fpath, "r", encoding="utf-8") as data_file: + data_loaded = json.load(data_file) + capture_resolution = data_loaded["capture resolution"] + stitch_resize = STITCHING_RESOLUTION[0] / capture_resolution[0] + except (json.decoder.JSONDecodeError, FileNotFoundError, TypeError): + # As there is no schema or pydantic model this should handle + # the file not being there, it not being json in the file, + # or the imported data not being indexable + logger.warning( + f"Couldn't read scan data, is {SCAN_DATA_FILENAME} missing or corrupt? " + "Attempting stitch with resize value of 0.5" + ) + stitch_resize = 0.5 + except KeyError: + logger.warning( + "Value for capture resolution not found in scan data. " + "Attempting stitch with resize value of 0.5" + ) + stitch_resize = 0.5 + self.run_subprocess( logger, [ @@ -1089,6 +1150,8 @@ class SmartScanThing(Thing): "--stitch_dzi", "--minimum_overlap", f"{round(overlap * 0.9, 2)}", + "--resize", + f"{stitch_resize}", images_folder, ], ) diff --git a/webapp/src/components/tabContentComponents/settingsComponents/cameraSettings.vue b/webapp/src/components/tabContentComponents/settingsComponents/cameraSettings.vue index 69f3f837..ddcef921 100644 --- a/webapp/src/components/tabContentComponents/settingsComponents/cameraSettings.vue +++ b/webapp/src/components/tabContentComponents/settingsComponents/cameraSettings.vue @@ -32,56 +32,6 @@ /> -