From 0c5e393116c465ab40f332c0a8157525bc5e6639 Mon Sep 17 00:00:00 2001 From: Mike Rosseel Date: Fri, 31 Jul 2026 01:53:09 +0200 Subject: [PATCH] feat(api): serve the raw sensor frame intact, and add rawfull /api/camera/raw rendered a uint16 sensor frame with Image.fromarray(arr, mode="L"), which reinterprets the 16-bit buffer as 8-bit. The result is interleaved-byte noise that still looks enough like an image to be believed -- I spent a night's captures on it before checking the histogram (median 1, p90 80, 1% at 255) and realising the frames were junk. 16-bit frames now render as mode "I;16" PNGs, preserving every ADU, and a test pins the round trip. Adds /api/camera/rawfull for the whole sensor including the margins the crop discards. Naming follows the exposure sweep's TIFFs: raw is the crop, rawfull is everything. The full frame is ~4 MB, so it is published on demand rather than on every capture -- the endpoint asks, the camera fulfils the request once and clears it. Between them these make the frames the pipeline actually works on retrievable from a running device, which is what debugging a solve failure in the field needs and what nothing currently offers. --- python/PiFinder/api_extensions.py | 67 +++++++++++++++++++++++------ python/PiFinder/camera_pi.py | 10 +++++ python/PiFinder/state.py | 23 ++++++++++ python/tests/test_api_extensions.py | 41 ++++++++++++++++++ 4 files changed, 128 insertions(+), 13 deletions(-) diff --git a/python/PiFinder/api_extensions.py b/python/PiFinder/api_extensions.py index 0bad6cf9a..563796055 100644 --- a/python/PiFinder/api_extensions.py +++ b/python/PiFinder/api_extensions.py @@ -46,6 +46,28 @@ def _png_response(img: Image.Image) -> Response: return Response(_pil_to_png_bytes(img), content_type="image/png") +def _raw_to_png(raw): + """Render a raw sensor frame as a PNG without destroying its values. + + Sensor frames are 10/12-bit held in uint16. Handing that buffer to + Image.fromarray(..., mode="L") reinterprets it as 8-bit and produces + interleaved-byte noise rather than an image -- which looked plausible + enough to waste a night's captures on. 16-bit frames therefore become + mode "I;16" PNGs, preserving every ADU for offline analysis. + """ + if hasattr(raw, "save"): # already a PIL image + return raw + + import numpy as np + + arr = np.asarray(raw) + if arr.ndim == 3: + return Image.fromarray(arr) + if arr.dtype == np.uint16: + return Image.fromarray(arr, mode="I;16") + return Image.fromarray(arr.astype(np.uint8), mode="L") + + def _pointing_to_dict(p): """Serialize a :class:`Pointing` (or ``None``) to a plain ``{RA, Dec, Roll}`` dict of floats.""" @@ -699,27 +721,46 @@ def api_screen(): @app.route("/api/camera/raw") def api_camera_raw(): - """Return the raw CMOS image, if available""" + """The cropped raw sensor frame -- what photometry measures.""" try: raw = server_instance.shared_state.cam_raw() if raw is None: return _json_response({"note": "No raw image available"}, 503) - # raw may be a PIL Image or a NumPy array - if hasattr(raw, "save"): - img = raw.convert("RGB") if raw.mode != "RGB" else raw - else: - import numpy as np - - arr = np.asarray(raw) - if arr.ndim == 2: - img = Image.fromarray(arr, mode="L").convert("RGB") - else: - img = Image.fromarray(arr) - return _png_response(img) + return _png_response(_raw_to_png(raw)) except Exception as e: logger.error("api/camera/raw error: %s", e) return _json_response({"error": str(e)}, 500) + @app.route("/api/camera/rawfull") + def api_camera_rawfull(): + """The whole sensor, uncropped -- margins included. + + Published on demand (the full frame is ~4 MB), so this asks the camera + for one and waits for the next capture to deliver it. Naming matches + the exposure sweep's "rawfull" TIFFs: raw is the crop, rawfull is + everything. + """ + import time as _time + + try: + state = server_instance.shared_state + state.set_cam_raw_full(None) + state.request_cam_raw_full() + # Long exposures make a capture cycle seconds long, so wait + # generously rather than reporting an absence that is just latency. + deadline = _time.time() + 15.0 + while _time.time() < deadline: + raw = state.cam_raw_full() + if raw is not None: + return _png_response(_raw_to_png(raw)) + _time.sleep(0.25) + return _json_response( + {"note": "Timed out waiting for a full-sensor frame"}, 504 + ) + except Exception as e: + logger.error("api/camera/rawfull error: %s", e) + return _json_response({"error": str(e)}, 500) + @app.route("/api/camera/debug") def api_camera_debug(): """Return the latest debug frame from the solver_debug_dumps directory""" diff --git a/python/PiFinder/camera_pi.py b/python/PiFinder/camera_pi.py index 4364bcd2f..a4b99d5fa 100644 --- a/python/PiFinder/camera_pi.py +++ b/python/PiFinder/camera_pi.py @@ -109,6 +109,16 @@ def capture(self) -> Image.Image: _request.release() + # Serve a pending request for the uncropped sensor frame before the + # crop discards the margins. On demand only: the full frame is ~4 MB + # and would cost that across the state manager on every capture. + if hasattr(self, "shared_state"): + try: + if self.shared_state.cam_raw_full_requested(): + self.shared_state.set_cam_raw_full(raw_capture.copy()) + except (BrokenPipeError, ConnectionResetError, AttributeError): + pass + # Apply camera-specific crop and rotation raw_capture = self.profile.crop_and_rotate(raw_capture) diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index 4b7c3cd0f..ca5bf6750 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -309,6 +309,8 @@ def __init__(self) -> None: # to the stored raw frame (PIL CCW). None until the camera reports. self.__solve_image_rotation = None self.__cam_raw = None + self.__cam_raw_full = None + self.__cam_raw_full_requested = False self.__sqm_radiometer_sample = None # Are we prepared to do alt/az math # We need gps lock and datetime @@ -563,6 +565,27 @@ def cam_raw(self): def set_cam_raw(self, v): self.__cam_raw = v + def cam_raw_full(self): + return self.__cam_raw_full + + def set_cam_raw_full(self, v): + # Fulfilling the request clears it, so one request yields one frame + # rather than leaving the camera publishing 4 MB every capture. + self.__cam_raw_full = v + self.__cam_raw_full_requested = False + + def cam_raw_full_requested(self) -> bool: + return self.__cam_raw_full_requested + + def request_cam_raw_full(self) -> None: + """Ask the camera to publish the next frame uncropped. + + The full sensor frame is ~4 MB and would cost that on every capture if + published unconditionally, so it is served on demand: a caller sets + this flag, the camera fulfils it once and clears it. + """ + self.__cam_raw_full_requested = True + def sqm_radiometer_sample(self): return self.__sqm_radiometer_sample diff --git a/python/tests/test_api_extensions.py b/python/tests/test_api_extensions.py index 8b318daf7..179f2571f 100644 --- a/python/tests/test_api_extensions.py +++ b/python/tests/test_api_extensions.py @@ -96,3 +96,44 @@ def test_diagnostics_and_timing_keys_preserved(): assert d["solve_time"] == 1234.5 assert d["cam_solve_time"] == 1234.5 json.dumps(d, default=str) # full payload is JSON-serializable + + +@pytest.mark.unit +def test_raw_png_preserves_16bit_values(): + """A 12-bit sensor frame must survive the PNG round trip intact. + + Rendering uint16 via Image.fromarray(..., mode="L") reinterprets the + 16-bit buffer as 8-bit and yields interleaved-byte noise that still looks + like a plausible image -- convincing enough to waste a night of captures + before anyone checks the histogram. Pin the values. + """ + import io + + import numpy as np + from PIL import Image + + from PiFinder.api_extensions import _raw_to_png as to_png + + frame = np.array([[0, 1, 255, 256], [4095, 2048, 300, 65535]], dtype=np.uint16) + + buf = io.BytesIO() + to_png(frame).save(buf, format="PNG") + buf.seek(0) + restored = np.asarray(Image.open(buf)) + + np.testing.assert_array_equal(restored, frame) + + +@pytest.mark.unit +def test_full_raw_request_is_one_shot(): + """One request yields one frame; the camera must not keep publishing 4 MB.""" + from PiFinder.state import SharedStateObj + + state = SharedStateObj() + assert state.cam_raw_full_requested() is False + + state.request_cam_raw_full() + assert state.cam_raw_full_requested() is True + + state.set_cam_raw_full(object()) + assert state.cam_raw_full_requested() is False