add camera images#70
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends the Tango device stack to support acquiring camera images using “advanced acquisition settings”, wiring a CAMERA settings device into registration and the microscope acquisition path, and updating an aberrations notebook to use the DB-registered devices.
Changes:
- Register and link a new CAMERA device (and CORRECTOR) in the Tango DB registration script.
- Add a
readout_areasetting to the CAMERA settings device and implement aget_camera_imagemicroscope command. - Add an AutoScript advanced camera acquisition helper to
ThermoMicroscopeand update the aberrations notebook setup/connection flow.
Reviewed changes
Copilot reviewed 5 out of 10 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/2_register_devices.py | Registers CAMERA/CORRECTOR devices and sets microscope device-address properties. |
| notebooks/2_Aberrations.ipynb | Adds setup/launch logic for Tango DB + servers; updates device addressing and shows new runtime output. |
| asyncroscopy/detectors/CAMERA.py | Adds readout_area acquisition preset as a read/write attribute with defaults. |
| asyncroscopy/ThermoMicroscope.py | Connects a camera proxy and adds AutoScript “advanced” camera acquisition helper; adjusts stage move and shift behavior. |
| asyncroscopy/Microscope.py | Adds camera_device_address property and implements get_camera_image command + updated metadata for get_scanned_image. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "ename": "KeyError", | ||
| "evalue": "'result'", | ||
| "output_type": "error", | ||
| "traceback": [ | ||
| "\u001b[31m---------------------------------------------------------------------------\u001b[39m", | ||
| "\u001b[31mKeyError\u001b[39m Traceback (most recent call last)", | ||
| "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 2\u001b[39m\n\u001b[32m 1\u001b[39m ab_msg = corrector_proxy.acquire_tableau(\u001b[33m'Enhanced 40'\u001b[39m)\n\u001b[32m----> \u001b[39m\u001b[32m2\u001b[39m ab = json.loads(ab_msg)[\u001b[33m'result'\u001b[39m][\u001b[33m'aberrations'\u001b[39m]\n\u001b[32m 3\u001b[39m \n\u001b[32m 4\u001b[39m ab[\u001b[33m'acceleration_voltage'\u001b[39m] = \u001b[32m60e3\u001b[39m \u001b[38;5;66;03m# eV\u001b[39;00m\n\u001b[32m 5\u001b[39m ab[\u001b[33m'FOV'\u001b[39m] = \u001b[32m100\u001b[39m /\u001b[32m10\u001b[39m \u001b[38;5;66;03m# nm\u001b[39;00m\n", | ||
| "\u001b[31mKeyError\u001b[39m: 'result'" | ||
| ] |
There was a problem hiding this comment.
The notebook output shows KeyError: 'result' when doing json.loads(ab_msg)['result']['aberrations'], which suggests the acquire_tableau() response schema doesn't match what this cell expects. Please adjust the parsing (or add a schema check with a clearer error) so the cell can run successfully without raising.
| this is the advanced version | ||
| """ | ||
|
|
||
| detector = 'BM-Ceta' # or "Flucam" |
There was a problem hiding this comment.
_acquire_camera_image ignores the detector argument by overwriting it with a hard-coded value ('BM-Ceta'). This makes the method signature misleading and prevents selecting other camera detectors. Either remove the parameter and document the fixed detector, or use the passed-in detector value (ideally validating it against supported detectors).
| detector = 'BM-Ceta' # or "Flucam" | |
| detector = detector or 'BM-Ceta' # default to BM-Ceta if no detector is provided |
|
|
||
| detector = 'BM-Ceta' # or "Flucam" | ||
|
|
||
| settings = CameraAcquisitionSettings(camera_detector=detector, size=imsize, | ||
| exposure_time=exposure_time, fixed_readout_area=readout_area, frame_combining=1) | ||
| adorned = self._microscope.acquisition.acquire_camera_image_advanced(settings) | ||
| image = adorned.data | ||
| return image | ||
|
|
||
|
|
There was a problem hiding this comment.
_acquire_camera_image uses self._microscope.acquisition... without checking self._microscope is not None (unlike _acquire_stem_image). In testing mode / when AutoScript is unavailable, this will raise an AttributeError instead of falling back or returning a controlled Tango error. Please add the same guard and either simulate a camera image or throw a Tango exception with a clear message.
| detector = 'BM-Ceta' # or "Flucam" | |
| settings = CameraAcquisitionSettings(camera_detector=detector, size=imsize, | |
| exposure_time=exposure_time, fixed_readout_area=readout_area, frame_combining=1) | |
| adorned = self._microscope.acquisition.acquire_camera_image_advanced(settings) | |
| image = adorned.data | |
| return image | |
| if self._microscope is not None: | |
| detector = 'BM-Ceta' # or "Flucam" | |
| settings = CameraAcquisitionSettings(camera_detector=detector, size=imsize, | |
| exposure_time=exposure_time, fixed_readout_area=readout_area, frame_combining=1) | |
| adorned = self._microscope.acquisition.acquire_camera_image_advanced(settings) | |
| image = adorned.data | |
| return image | |
| # Simulation fallback | |
| self.warn_stream( | |
| f"Simulating camera acquisition for detector={detector}, " | |
| f"readout_area={readout_area}, size={imsize}" | |
| ) | |
| rng = np.random.default_rng() | |
| return rng.integers(0, 65535, size=(imsize, imsize), dtype=np.uint16) |
| """Apply image shift in meters.""" | ||
| x_shift = float(shift[0]) | ||
| y_shift = float(shift[1]) | ||
| print(shift) |
There was a problem hiding this comment.
print(shift) is left in _set_image_shift, which will spam stdout in a running Tango server. Please remove it or replace it with self.debug_stream(...)/info_stream(...) depending on desired verbosity.
| print(shift) | |
| self.debug_stream(f"Applying image shift: {shift}") |
| image = self._acquire_stem_image(imsize, dwell_time, ['haadf']) | ||
|
|
||
| metadata = { | ||
| "detector": 'haadf', | ||
| "shape": [imsize, imsize], | ||
| "dtype": str(adorned_image.dtype), | ||
| "dtype": str(image.dtype), | ||
| "dwell_time": dwell_time, | ||
| "timestamp": time.time(), | ||
| "fov": self._get_fov(), | ||
| # TODO: add metadata from adorned_image.metadata when using real AutoScript | ||
| } | ||
|
|
||
| return json.dumps(metadata), adorned_image.tobytes() | ||
| return json.dumps(metadata), image.tobytes() |
There was a problem hiding this comment.
get_scanned_image now unconditionally adds "fov": self._get_fov(). In ThermoMicroscope testing mode / when AutoScript is unavailable, _microscope stays None and _get_fov() currently dereferences self._microscope, which will raise and break existing tests/CI. Please guard this (e.g., only include fov when connected, or have _get_fov() return a safe default in simulation/testing mode).
| @command(dtype_out=DevEncoded) | ||
| def get_camera_image(self) -> tuple[str, bytes]: | ||
| """ | ||
| Acquire a single camera image from the named detector. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| detector_name: | ||
| Name of the detector, e.g. "BM-Ceta". Must match a key in | ||
| self._detector_proxies. | ||
|
|
||
| Returns | ||
| ------- | ||
| DevEncoded = (json_metadata, raw_bytes) | ||
| json_metadata includes: shape, dtype, dwell_time, detector, | ||
| timestamp, and any other relevant metadata. | ||
| raw_bytes is the flat numpy array bytes; reshape using shape from metadata. | ||
| """ | ||
|
|
||
| # check active detectors | ||
| camera = self._detector_proxies.get("camera") | ||
|
|
||
| # Read settings from the detector | ||
| exposure_time=camera.exposure_time | ||
| imsize=camera.imsize | ||
| readout_area=camera.readout_area | ||
|
|
||
| image = self._acquire_camera_image(imsize=imsize, exposure_time=exposure_time, | ||
| detector='BM-Ceta', readout_area=readout_area) | ||
|
|
||
| metadata = { | ||
| "detector": 'Ceta', | ||
| "shape": [imsize, imsize], |
There was a problem hiding this comment.
get_camera_image's docstring describes a detector_name parameter, but the command signature takes no inputs and hard-codes the detector ('BM-Ceta' / 'Ceta'). This is confusing for API consumers; either accept a detector name as dtype_in (and pass it through to acquisition + metadata) or update the docstring/metadata to reflect the fixed detector behavior.
| camera = self._detector_proxies.get("camera") | ||
|
|
||
| # Read settings from the detector | ||
| exposure_time=camera.exposure_time | ||
| imsize=camera.imsize | ||
| readout_area=camera.readout_area |
There was a problem hiding this comment.
get_camera_image assumes the camera settings proxy exists and immediately reads camera.exposure_time/imsize/readout_area. If camera_device_address isn't configured or the proxy connection failed, this will raise an AttributeError instead of a controlled Tango error. Please add a None check (similar to get_spectrum) and throw a tango.Except.throw_exception(...) with available detectors / configuration hints.
| camera = self._detector_proxies.get("camera") | |
| # Read settings from the detector | |
| exposure_time=camera.exposure_time | |
| imsize=camera.imsize | |
| readout_area=camera.readout_area | |
| detector_proxies = getattr(self, "_detector_proxies", {}) or {} | |
| camera = detector_proxies.get("camera") | |
| if camera is None: | |
| available_detectors = ", ".join(sorted(detector_proxies.keys())) or "none" | |
| tango.Except.throw_exception( | |
| "Detector unavailable", | |
| "Camera detector proxy is not configured or could not be connected. " | |
| f"Available detectors: {available_detectors}. " | |
| "Please configure 'camera_device_address' and ensure the camera " | |
| "device proxy can be created successfully.", | |
| "Microscope.get_camera_image", | |
| ) | |
| # Read settings from the detector | |
| exposure_time = camera.exposure_time | |
| imsize = camera.imsize | |
| readout_area = camera.readout_area |
| @command(dtype_out=DevEncoded) | ||
| def get_camera_image(self) -> tuple[str, bytes]: | ||
| """ | ||
| Acquire a single camera image from the named detector. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| detector_name: | ||
| Name of the detector, e.g. "BM-Ceta". Must match a key in | ||
| self._detector_proxies. | ||
|
|
||
| Returns | ||
| ------- | ||
| DevEncoded = (json_metadata, raw_bytes) | ||
| json_metadata includes: shape, dtype, dwell_time, detector, | ||
| timestamp, and any other relevant metadata. | ||
| raw_bytes is the flat numpy array bytes; reshape using shape from metadata. | ||
| """ | ||
|
|
||
| # check active detectors | ||
| camera = self._detector_proxies.get("camera") | ||
|
|
||
| # Read settings from the detector | ||
| exposure_time=camera.exposure_time | ||
| imsize=camera.imsize | ||
| readout_area=camera.readout_area | ||
|
|
||
| image = self._acquire_camera_image(imsize=imsize, exposure_time=exposure_time, | ||
| detector='BM-Ceta', readout_area=readout_area) | ||
|
|
||
| metadata = { | ||
| "detector": 'Ceta', | ||
| "shape": [imsize, imsize], | ||
| "dtype": str(image.dtype), | ||
| "exposure_time": exposure_time, | ||
| "timestamp": time.time(), | ||
| "fov": self._get_fov(), | ||
| "readout_area": readout_area, | ||
| # TODO: move this metadata packing into the _acquire_camera_image method | ||
| # when usingreal AutoScript,to include metadata from adorned_image.metadata | ||
| } | ||
|
|
||
| return json.dumps(metadata), image.tobytes() | ||
|
|
There was a problem hiding this comment.
New camera acquisition behavior is introduced via get_camera_image, but there are existing tests for get_scanned_image in tests/test_thermo_microscope.py and no corresponding coverage for get_camera_image. Adding a test that provisions a CAMERA settings device in MultiDeviceTestContext and patches _acquire_camera_image (similar to patched_single_image) would help prevent regressions in metadata packing and settings propagation.
| def _acquire_stem_image_advanced(): | ||
| print("Get image with more flexible settings") | ||
| pass | ||
| def _acquire_camera_image(): |
There was a problem hiding this comment.
The base Microscope class adds a _acquire_camera_image hook, but it is missing a self parameter and isn't marked @abstractmethod like the other acquisition helpers. This makes it easy for subclasses to accidentally not override it (or to mismatch signatures). Consider defining it as @abstractmethod def _acquire_camera_image(self, ...): ... with the expected parameters.
| def _acquire_camera_image(): | |
| @abstractmethod | |
| def _acquire_camera_image(self): |
| "import os\n", | ||
| "import tango\n", | ||
| "import json\n", | ||
| "import numpy as np\n", | ||
| "import matplotlib.pyplot as plt" |
There was a problem hiding this comment.
The imports cell now includes several duplicate imports (tango, json, numpy, matplotlib.pyplot). This makes the notebook harder to maintain and can mask version/namespace issues; please de-duplicate the imports in this cell.
| "import os\n", | |
| "import tango\n", | |
| "import json\n", | |
| "import numpy as np\n", | |
| "import matplotlib.pyplot as plt" | |
| "import os" |
camera images now, done with advanced acquisition settings