From 7fdd2e124df8fcee9babab7ab9520768e9b68ac9 Mon Sep 17 00:00:00 2001 From: hjoungjoo <149982795+hjoungjoo@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:07:05 +0900 Subject: [PATCH] Improve focus preview and camera gain control --- python/PiFinder/camera_interface.py | 20 ++++++-- python/PiFinder/ui/callbacks.py | 74 ++++++++++++++++++++++++++ python/PiFinder/ui/menu_structure.py | 59 +++++++++++++++++++++ python/PiFinder/ui/preview.py | 77 ++++++++++++++++++++++++++-- 4 files changed, 223 insertions(+), 7 deletions(-) diff --git a/python/PiFinder/camera_interface.py b/python/PiFinder/camera_interface.py index 52a94ef3e..17d2d5e81 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -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 @@ -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] diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index dcdb35fb9..817b3741c 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -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 @@ -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") diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index 5a8b97f66..0ba0c8e5d 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -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, diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index 5815f760a..a45d6a43d 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -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); @@ -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): @@ -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 @@ -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: @@ -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)