Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions python/PiFinder/api_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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"""
Expand Down
10 changes: 10 additions & 0 deletions python/PiFinder/camera_pi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions python/PiFinder/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions python/tests/test_api_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading