From eaa9e1009bed1963be0b5a0f9a3f6aced3bc916b Mon Sep 17 00:00:00 2001 From: GotthardG <51994228+GotthardG@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:23:41 +0200 Subject: [PATCH] feat: send beam-mark centre as image metadata (overlay, not baked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sample screenshots now carry the beam mark as normalized metadata for the AareDB frontend overlay — the JPEG stays unmarked so raw images remain clean for YOLO training. - upload_image: optional `beam_center` sent as a JSON form field. - ScreenshotService.save_to_db: normalizes the beam mark by the captured frame dims -> {x,y,ar,[w,h]} via _beam_center_metadata (+ unit tests). - save_screenshot_db: reads get_beam_mark() (centre) best-effort. Beam SIZE in px is a TODO(calibration) hook -> crosshair until filled, then a rectangle matching the gridscan. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aare/daq/aaredb.py | 13 +++++-- src/aare/daq/daq.py | 23 +++++++++++- src/aare/daq/operations/screenshot/service.py | 35 +++++++++++++++++-- .../screenshot/test_screenshot_service.py | 25 ++++++++++++- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/aare/daq/aaredb.py b/src/aare/daq/aaredb.py index 5cd69dc9..6dfc01f1 100644 --- a/src/aare/daq/aaredb.py +++ b/src/aare/daq/aaredb.py @@ -131,7 +131,7 @@ class AareWrapper: logger.error(f"Error sending sample event {event_type!s} to db: {e}") @log_timing(logger, "AareDB call") - def upload_image(self, sample_id: int, filename: str, bgr_image: np.ndarray, message: Optional[str] = None): + def upload_image(self, sample_id: int, filename: str, bgr_image: np.ndarray, message: Optional[str] = None, beam_center: Optional[dict] = None): _, buffer = cv2.imencode('.jpg', bgr_image) jpeg_bytes = io.BytesIO(buffer) url = f"{self.__host}/protected_router/sample_runner/{sample_id}/upload-images" @@ -140,14 +140,21 @@ class AareWrapper: "X-Shared-Password": os.getenv("AAREDB_SHARED_PASSWORD") } + data = {} + if message is not None: + data["comment"] = message + if beam_center is not None: + # Overlay metadata only — the JPEG stays unmarked (kept clean for YOLO). + data["beam_center"] = json.dumps(beam_center) + request_kwargs = { "files": {'uploaded_file': (filename + ".jpg", jpeg_bytes, "image/jpeg")}, "verify": self.__ssl_ca_cert, "cert": (self.__cert_file, self.__key_file), "headers": headers, } - if message is not None: - request_kwargs["data"] = {"comment": message} + if data: + request_kwargs["data"] = data response = requests.post(url, **request_kwargs) logger.debug(f"Response status code: {response.status_code}") diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index 22628291..25d47bb4 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -2506,7 +2506,28 @@ class AareDAQ: filename: Name to give to the uploaded image. settle_time_s: float time to wait before taking the screenshot. """ - self._screenshot_service.save_to_db(sample_id, filename, settle_time_s) + # Beam-mark centre in sample-camera pixels at the current zoom, for the + # DB overlay (drawn in the frontend, never baked into the JPEG). Best + # effort — a calibration hiccup must never block the screenshot upload. + beam_mark_pxl = None + beam_size_pxl = None + try: + beam_mark_pxl = self.get_beam_mark() + # TODO(calibration): beam SIZE in sample-camera pixels so the overlay + # is a rectangle instead of a crosshair, e.g. + # px_per_mm = ... # sample-camera scale at self.__devs.zoom + # bs = self.__cfg.beam_size_mm + # beam_size_pxl = (bs.x * px_per_mm, bs.y * px_per_mm) + except Exception: + logger.warning("Could not read beam mark for screenshot overlay", exc_info=True) + + self._screenshot_service.save_to_db( + sample_id, + filename, + settle_time_s, + beam_mark_pxl=beam_mark_pxl, + beam_size_pxl=beam_size_pxl, + ) def send_screenshot_db(self, filename: str | None = None, message: str | None = None) -> None: sample = self.sample diff --git a/src/aare/daq/operations/screenshot/service.py b/src/aare/daq/operations/screenshot/service.py index 5906aa49..c0ae2a26 100644 --- a/src/aare/daq/operations/screenshot/service.py +++ b/src/aare/daq/operations/screenshot/service.py @@ -68,13 +68,44 @@ class ScreenshotService: self.logger.debug(f"saving screenshot {filename} from inference image") cv2.imwrite(f"{self.output_dir}/{filename}.jpg", bgr_image) - def save_to_db(self, sample_id: int, filename: str, settle_time_s: float = 0.2) -> None: + @staticmethod + def _beam_center_metadata(bgr_image, beam_mark_pxl, beam_size_pxl): + """Normalized beam-mark rectangle {x, y, w, h, ar} (0-1) for the DB + overlay, or None. `beam_mark_pxl` = (x, y) centre in image pixels; + `beam_size_pxl` = (w, h) size in image pixels (optional -> crosshair).""" + if beam_mark_pxl is None or bgr_image is None: + return None + try: + h, w = bgr_image.shape[:2] + if not w or not h: + return None + data = { + "x": float(beam_mark_pxl[0]) / w, + "y": float(beam_mark_pxl[1]) / h, + "ar": float(w) / float(h), + } + if beam_size_pxl is not None: + data["w"] = float(beam_size_pxl[0]) / w + data["h"] = float(beam_size_pxl[1]) / h + return data + except (TypeError, ValueError, IndexError, ZeroDivisionError): + return None + + def save_to_db( + self, + sample_id: int, + filename: str, + settle_time_s: float = 0.2, + beam_mark_pxl=None, + beam_size_pxl=None, + ) -> None: time.sleep(settle_time_s) sample = self._current_sample() bgr_image = self._get_inference_image() + beam_center = self._beam_center_metadata(bgr_image, beam_mark_pxl, beam_size_pxl) def _upload() -> None: - self.aare.upload_image(sample_id, filename, bgr_image) + self.aare.upload_image(sample_id, filename, bgr_image, beam_center=beam_center) self.run_noncritical( _upload, diff --git a/tests/unit/daq/operations/screenshot/test_screenshot_service.py b/tests/unit/daq/operations/screenshot/test_screenshot_service.py index 5680da8e..1d792dc7 100644 --- a/tests/unit/daq/operations/screenshot/test_screenshot_service.py +++ b/tests/unit/daq/operations/screenshot/test_screenshot_service.py @@ -102,4 +102,27 @@ def test_send_to_db_requires_mounted_sample(mock_logger): ) with pytest.raises(ValueError, match="valid sample_id"): - service.send_to_db(default_message="x") \ No newline at end of file + service.send_to_db(default_message="x") + + +def test_beam_center_metadata_centre_only(): + img = np.zeros((100, 200, 3), dtype=np.uint8) # h=100, w=200 -> ar 2.0 + bc = ScreenshotService._beam_center_metadata(img, (100, 50), None) + assert bc == pytest.approx({"x": 0.5, "y": 0.5, "ar": 2.0}) + + +def test_beam_center_metadata_with_size(): + img = np.zeros((100, 200, 3), dtype=np.uint8) + bc = ScreenshotService._beam_center_metadata(img, (100, 50), (20, 10)) + assert bc == pytest.approx({"x": 0.5, "y": 0.5, "ar": 2.0, "w": 0.1, "h": 0.1}) + + +def test_beam_center_metadata_none_without_mark_or_image(): + img = np.zeros((10, 10, 3), dtype=np.uint8) + assert ScreenshotService._beam_center_metadata(img, None, None) is None + assert ScreenshotService._beam_center_metadata(None, (1, 2), None) is None + + +def test_beam_center_metadata_tolerates_bad_input(): + img = np.zeros((10, 10, 3), dtype=np.uint8) + assert ScreenshotService._beam_center_metadata(img, ("a", "b"), None) is None \ No newline at end of file