Skip to content
Draft
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
20 changes: 16 additions & 4 deletions python/PiFinder/camera_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ def set_native_ae(self, enabled: bool) -> bool:
"""
return False

def get_default_gain(self) -> float:
"""Return the backend's default startup gain."""
profile = getattr(self, "profile", None)
if profile is not None and hasattr(profile, "analog_gain"):
return float(profile.analog_gain)
return float(getattr(self, "gain", 1.0))

def initialize(self) -> None:
pass

Expand Down Expand Up @@ -441,13 +448,18 @@ def get_image_loop(
)

if command.startswith("set_gain"):
old_gain = self.gain
self.gain = int(command.split(":")[1])
old_gain = getattr(self, "gain", self.get_default_gain())
gain_value = command.split(":", 1)[1]
if gain_value == "profile":
self.gain = self.get_default_gain()
else:
self.gain = float(gain_value)
self.exposure_time, self.gain = self.set_camera_config(
self.exposure_time, self.gain
)
console_queue.put("CAM: Gain=" + str(self.gain))
logger.info(f"Gain changed: {old_gain}x → {self.gain}x")
gain_text = f"{self.gain:g}"
console_queue.put("CAM: Gain=" + gain_text)
logger.info(f"Gain changed: {old_gain:g}x → {gain_text}x")

if command.startswith("set_ae_mode"):
mode = command.split(":")[1]
Expand Down
74 changes: 74 additions & 0 deletions python/PiFinder/ui/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import Any, TYPE_CHECKING
from PiFinder import utils, calc_utils
from PiFinder.locations import Location as SavedLocation
from PiFinder.sqm.camera_profiles import get_camera_profile
from PiFinder.state import Location
from PiFinder.ui.base import UIModule
from PiFinder.ui.textentry import UITextEntry
Expand Down Expand Up @@ -92,6 +93,79 @@ def set_exposure(ui_module: UIModule) -> None:
ui_module.command_queues["camera"].put(f"set_exp:{new_exposure}")


def _format_gain(gain: float | int | None) -> str:
if gain is None:
return ""
gain_float = float(gain)
if gain_float.is_integer():
return f"{int(gain_float)}x"
return f"{gain_float:g}x"


def _get_current_camera_gain(ui_module: UIModule) -> float | None:
try:
metadata = ui_module.shared_state.last_image_metadata()
if metadata and "gain" in metadata:
return float(metadata["gain"])
except Exception:
return None
return None


def _get_profile_camera_gain(ui_module: UIModule) -> float | None:
try:
cam_type = get_camera_type(ui_module)[0]
return float(get_camera_profile(cam_type).analog_gain)
except Exception:
return None


def get_camera_gain_selection(ui_module: UIModule) -> list[float | str | None]:
"""
Return the current runtime camera gain for the gain menu checkmark.
"""
current_gain = _get_current_camera_gain(ui_module)
profile_gain = _get_profile_camera_gain(ui_module)

if current_gain is None:
return ["profile"]

if profile_gain is not None and abs(current_gain - profile_gain) < 0.05:
return ["profile"]

if current_gain.is_integer():
return [int(current_gain)]
return [current_gain]


def get_camera_profile_gain_display(ui_module: UIModule) -> str:
"""
Return the profile gain suffix shown beside the Profile gain item.
"""
profile_gain = _get_profile_camera_gain(ui_module)
if profile_gain is None:
return ""
return f" ({_format_gain(profile_gain)})"


def set_gain(ui_module: UIModule) -> None:
"""
Set runtime camera gain from the current Camera Gain menu item.
"""
selected_item = ui_module._menu_items[ui_module._current_item_index]
selected_item_definition = ui_module.get_item(selected_item)
new_gain = selected_item_definition["value"]

if new_gain == "profile":
logger.info("Set gain to camera profile default")
ui_module._selected_values = ["profile"]
else:
logger.info("Set gain %s", new_gain)
ui_module._selected_values = [new_gain]

ui_module.command_queues["camera"].put(f"set_gain:{new_gain}")


def apply_brightness(ui_module: UIModule) -> None:
"""Re-apply display + keypad brightness from current config."""
ui_module.command_queues["ui_queue"].put("set_brightness")
Expand Down
59 changes: 59 additions & 0 deletions python/PiFinder/ui/menu_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,65 @@ def _(key: str) -> Any:
},
],
},
{
"name": _("Camera Gain"),
"class": UITextMenu,
"select": "single",
"label": "camera_gain",
"value_callback": callbacks.get_camera_gain_selection,
"post_callback": callbacks.set_gain,
"items": [
{
"name": _("Profile"),
"value": "profile",
"name_suffix_callback": callbacks.get_camera_profile_gain_display,
},
{
"name": _("1x"),
"value": 1,
},
{
"name": _("2x"),
"value": 2,
},
{
"name": _("4x"),
"value": 4,
},
{
"name": _("8x"),
"value": 8,
},
{
"name": _("12x"),
"value": 12,
},
{
"name": _("15x"),
"value": 15,
},
{
"name": _("16x"),
"value": 16,
},
{
"name": _("20x"),
"value": 20,
},
{
"name": _("22x"),
"value": 22,
},
{
"name": _("24x"),
"value": 24,
},
{
"name": _("30x"),
"value": 30,
},
],
},
{
"name": _("WiFi Mode"),
"class": UITextMenu,
Expand Down
77 changes: 74 additions & 3 deletions python/PiFinder/ui/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
STRETCH_EMA_ALPHA = 0.15 # display-stretch black/white smoothing (lower = calmer)
STRETCH_MIN_SPAN = 50.0 # min ADU span so a faint frame isn't stretched hard
STRETCH_DITHER_FRAC = 0.5 # uniform dither amplitude as a fraction of one step
STRETCH_BRIGHT_BACKGROUND = 220.0 # show saturated/daylit focus frames directly

# Native camera frame size. target_pixel and centroid coordinates live in this
# (square) pixel space (see SharedStateObj.target_pixel, documented 512x512);
Expand Down Expand Up @@ -80,7 +81,10 @@ def __init__(self, *args, **kwargs):
menu_jump="camera_exposure",
),
down=MarkingMenuOption(),
right=MarkingMenuOption(),
right=MarkingMenuOption(
label=_("Gain"),
menu_jump="camera_gain",
),
)

def _reset_focus_state(self):
Expand Down Expand Up @@ -145,6 +149,15 @@ def _apply_stretch(self, image_obj):
if self._stretch_black is None or self._stretch_white is None:
return image_obj
black = self._stretch_black

# The normal focus preview stretch assumes a dark sky: it maps the
# measured background to black so faint stars stand out. With daytime or
# saturated frames the background can already be near white; applying
# that same mapping turns the whole preview black. Keep the current
# exposure/gain intact and render those bright frames directly.
if black >= STRETCH_BRIGHT_BACKGROUND:
return image_obj

span = max(self._stretch_white - black, STRETCH_MIN_SPAN)
scale = 255.0 / span

Expand All @@ -157,6 +170,51 @@ def _apply_stretch(self, image_obj):
np.clip(stretched, 0, 255, out=stretched)
return Image.fromarray(stretched.astype(np.uint8), mode="L")

def _orient_camera_image(self, image_obj):
camera_rotation = self.config_object.get_option("camera_rotation")
if camera_rotation is not None:
return image_obj.rotate(int(camera_rotation) * -1)

screen_direction = self.config_object.get_option("screen_direction")
if screen_direction in ["right", "straight", "flat3", "as_bloom"]:
return image_obj.rotate(90)
return image_obj.rotate(270)

def _raw_display_image(self):
raw = self.shared_state.cam_raw()
if raw is None:
return None

arr = np.asarray(raw)
if arr.ndim != 2:
return None

arr = arr.astype(np.float32, copy=False)
arr = arr[: arr.shape[0] // 2 * 2, : arr.shape[1] // 2 * 2]
if arr.shape[0] >= 2 and arr.shape[1] >= 2:
# Average the nominal Bayer quad. This also reduces the checker
# pattern on mono sensors reported through an RGGB driver.
arr = (
arr[0::2, 0::2]
+ arr[0::2, 1::2]
+ arr[1::2, 0::2]
+ arr[1::2, 1::2]
) * 0.25

low = float(np.percentile(arr, 1.0))
high = float(np.percentile(arr, 99.5))
if high <= low + 1.0:
# This helper is only used after the processed preview has already
# been classified as bright. A saturated or nearly flat bright raw
# frame has no percentile span; stretching it from low to low+1
# would map the whole image to black. Keep it bright instead.
scaled = np.full(arr.shape, 255, dtype=np.float32)
else:
scaled = (arr - low) * (255.0 / (high - low))
np.clip(scaled, 0, 255, out=scaled)
image_obj = Image.fromarray(scaled.astype(np.uint8), mode="L")
return self._orient_camera_image(image_obj)

def draw_star_selectors(self):
# Draw star selectors
if self.star_list.shape[0] > 0:
Expand Down Expand Up @@ -409,18 +467,31 @@ def update(self, force=False):
self._last_focus_frame_time = last_image_time

resX, resY = self.display_class.resX, self.display_class.resY
display_image = raw_image
stretch_display = True
if (
self._stretch_black is not None
and self._stretch_black >= STRETCH_BRIGHT_BACKGROUND
):
raw_display = self._raw_display_image()
if raw_display is not None:
display_image = raw_display
stretch_display = False

# Resize / zoom. Zoom crops a centred region of the native camera
# frame (half of it for 2x, a quarter for 4x) then scales to the
# display, so the zoom factor stays 2x / 4x at any resolution.
# (Shared with the daytime-align screen via ui.camera_render.)
image_obj = resize_for_display(raw_image, (resX, resY), self.zoom_level)
image_obj = resize_for_display(
display_image, (resX, resY), self.zoom_level
)

# Background-anchored linear stretch (replaces autocontrast), then RED.
# Stretch on a single luminance band (debug frames are RGB; hardware
# frames are already mode "L").
image_obj = image_obj.convert("L")
image_obj = self._apply_stretch(image_obj)
if stretch_display:
image_obj = self._apply_stretch(image_obj)
image_obj = image_obj.convert("RGB")
image_obj = ImageChops.multiply(image_obj, self.colors.red_image)

Expand Down