Skip to content
Open
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
1 change: 1 addition & 0 deletions default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"screen_direction": "right",
"mount_type": "Alt/Az",
"solver_debug": 0,
"solver_full_frame": true,
"sleep_timeout": "30s",
"screen_off_timeout": "Off",
"chart_radec": "Off",
Expand Down
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
6 changes: 4 additions & 2 deletions python/PiFinder/camera_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ def get_cam_type(self) -> str:
return self.camType


def get_images(shared_state, camera_image, command_queue, console_queue, log_queue):
def get_images(
shared_state, camera_image, solve_image, command_queue, console_queue, log_queue
):
"""
Instantiates the camera hardware
then calls the universal image loop
Expand All @@ -116,5 +118,5 @@ def get_images(shared_state, camera_image, command_queue, console_queue, log_que

camera_hardware = CameraDebug(exposure_time)
camera_hardware.get_image_loop(
shared_state, camera_image, command_queue, console_queue, cfg
shared_state, camera_image, solve_image, command_queue, console_queue, cfg
)
112 changes: 104 additions & 8 deletions python/PiFinder/camera_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ExposureSNRController,
generate_exposure_sweep,
)
from PiFinder.optics import DISPLAY_FRAME_SIZE, SolveGeometry, build_geometry

logger = logging.getLogger("Camera.Interface")

Expand Down Expand Up @@ -179,6 +180,15 @@ def initialize(self) -> None:
def capture(self) -> Image.Image:
return Image.Image()

def capture_pair(self) -> Tuple[Image.Image, Image.Image]:
"""Return ``(display_frame, solve_frame)`` from a single exposure.

Backends with no separate full-sensor readout solve the display frame,
so both halves are the same image.
"""
image = self.capture()
return image, image

def capture_file(self, filename) -> None:
pass

Expand All @@ -191,6 +201,12 @@ def _blank_capture(self):
"""
return Image.new("L", (512, 512), 0) # Black 512x512 image

def _capture_pair_with_timeout(
self, timeout=10
) -> Optional[Tuple[Image.Image, Image.Image]]:
""":meth:`capture_pair` under the same guard as :meth:`_capture_with_timeout`."""
return self._run_capture_with_timeout(self.capture_pair, timeout)

def _capture_with_timeout(self, timeout=10) -> Optional[Image.Image]:
"""Run capture() with a timeout, never overlapping two captures.

Expand All @@ -207,6 +223,10 @@ def _capture_with_timeout(self, timeout=10) -> Optional[Image.Image]:
second capture while it is still alive. At most one capture is ever
running; the caller just gets blank frames until the stuck one clears.
"""
return self._run_capture_with_timeout(self.capture, timeout)

def _run_capture_with_timeout(self, capture_fn, timeout=10):
"""Shared timeout/overlap guard behind the capture wrappers."""
# A previous capture is still wedged in the driver -- don't start a
# second one. Returning None lets the caller fall back to a blank frame
# while we wait for the stuck capture to clear.
Expand All @@ -218,7 +238,7 @@ def _capture_with_timeout(self, timeout=10) -> Optional[Image.Image]:

def _do_capture():
try:
result[0] = self.capture()
result[0] = capture_fn()
except Exception as e: # propagate to the caller's thread
exc[0] = e

Expand Down Expand Up @@ -262,8 +282,45 @@ def start_camera(self) -> None:
def stop_camera(self) -> None:
pass

def _configure_solve_geometry(
self, shared_state, cfg, solve_rotation
) -> SolveGeometry:
"""Decide whether to solve on the full sensor, and publish the mapping.

Full frame needs a camera profile describing the sensor, and needs the
frame's orientation to be a multiple of 90 degrees -- an arbitrary
rotation would clip the corners off a non-square frame, which is
exactly the sky area full frame exists to recover.
"""
profile = getattr(self, "profile", None)
full_frame = bool(cfg.get_option("solver_full_frame", True))

if full_frame and profile is None:
logger.info("Camera has no sensor profile; solving on the display frame")
full_frame = False
if full_frame and solve_rotation % 90:
logger.warning(
"Solve rotation %s is not a quarter turn; solving on the display frame",
solve_rotation,
)
full_frame = False

geometry = build_geometry(profile, solve_rotation, full_frame)
shared_state.set_solve_geometry(geometry.as_dict())
if geometry.full_frame:
logger.info(
"Full-frame solving enabled: solve frame %dx%d (display frame %dx%d)",
geometry.solve_width,
geometry.solve_height,
DISPLAY_FRAME_SIZE,
DISPLAY_FRAME_SIZE,
)
else:
logger.info("Solving on the %dx%d display frame", *geometry.solve_size)
return geometry

def get_image_loop(
self, shared_state, camera_image, command_queue, console_queue, cfg
self, shared_state, camera_image, solve_image, command_queue, console_queue, cfg
):
try:
# Store shared_state for access by capture() methods
Expand Down Expand Up @@ -300,6 +357,8 @@ def get_image_loop(
solve_rotation = SCREEN_ROTATE_AMOUNTS.get(screen_direction, 270)
shared_state.set_solve_image_rotation(solve_rotation)

geometry = self._configure_solve_geometry(shared_state, cfg, solve_rotation)

# Set path for test mode image
root_dir = os.path.realpath(
os.path.join(os.path.dirname(__file__), "..", "..")
Expand Down Expand Up @@ -336,18 +395,41 @@ def get_image_loop(
imu_start = shared_state.imu()
image_start_time = time.time()
if self._camera_started:
solve_frame = None
if not test_mode_on:
base_image = self._capture_with_timeout()
if base_image is None:
if geometry.full_frame:
captured = self._capture_pair_with_timeout()
else:
image = self._capture_with_timeout()
captured = None if image is None else (image, None)
if captured is None:
# Capture hung; fall back to a blank frame so the
# loop keeps running and stays responsive to
# commands instead of freezing. The blank frame
# simply fails to solve.
logger.warning("Camera capture timed out; blank frame")
base_image = self._blank_capture()
captured = (self._blank_capture(), None)
base_image, solve_frame = captured
base_image = base_image.convert("L")

base_image = base_image.rotate(solve_rotation)

if solve_frame is not None:
solve_frame = solve_frame.convert("L")
# The solve frame is not square, so rotate it by
# transposition: Image.rotate() clips the corners.
if solve_rotation % 360 == 90:
solve_frame = solve_frame.transpose(
Image.Transpose.ROTATE_90
)
elif solve_rotation % 360 == 180:
solve_frame = solve_frame.transpose(
Image.Transpose.ROTATE_180
)
elif solve_rotation % 360 == 270:
solve_frame = solve_frame.transpose(
Image.Transpose.ROTATE_270
)
else:
# Test Mode: load image from disc and wait
# No real raw matrix backs this frame; prevent a recent
Expand Down Expand Up @@ -377,9 +459,23 @@ def get_image_loop(
if test_mode_on and abs(pointing_diff) > 0.01:
# Scope moved during the fake exposure: return a blank
# image so the solver doesn't report a stale solve
camera_image.paste(self._blank_capture())
else:
camera_image.paste(base_image)
base_image = self._blank_capture()
solve_frame = None
camera_image.paste(base_image)
if solve_frame is None:
if geometry.full_frame:
# No full-sensor frame this cycle (timed-out
# capture, or test mode). Publish a blank at the
# full solve size rather than a 512x512 frame,
# which would leave the previous exposure's stars
# sitting in the region outside it for the solver
# to find again.
solve_frame = Image.new("L", geometry.solve_size[::-1], 0)
else:
# Backends with no separate full-sensor readout
# solve the display frame.
solve_frame = base_image
solve_image.paste(solve_frame, (0, 0))
image_metadata = {
"exposure_start": image_start_time,
"exposure_end": image_end_time,
Expand Down
6 changes: 4 additions & 2 deletions python/PiFinder/camera_none.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ def get_cam_type(self) -> str:
return self.camType


def get_images(shared_state, camera_image, bias_image, command_queue, console_queue):
def get_images(
shared_state, camera_image, solve_image, command_queue, console_queue, log_queue
):
"""
Instantiates the camera hardware
then calls the universal image loop
Expand All @@ -70,5 +72,5 @@ def get_images(shared_state, camera_image, bias_image, command_queue, console_qu

camera_hardware = CameraNone(exposure_time)
camera_hardware.get_image_loop(
shared_state, camera_image, bias_image, command_queue, console_queue, cfg
shared_state, camera_image, solve_image, command_queue, console_queue, cfg
)
Loading
Loading