From d9c93ae7437c425a480ea8c3ff2ba045c14c6394 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 29 May 2026 11:34:10 -0700 Subject: [PATCH 1/4] Add focus-quality (HFD) indicator to the Focus screen Self-contained Half-Flux Diameter detector (PiFinder/focus.py) plus a focus strip in UIPreview: log-axis V-curve over a 10s window, best-focus marker, past-best "BACK UP" cue, and a background-anchored display stretch replacing per-frame autocontrast. The detector runs in the main process on the raw frame and does not depend on plate solving, so it works across the full defocus range. See docs/adr/0005-focus-hfd-self-contained-in-ui.md and the "Focus indicator" section of docs/ax/ui/CONTEXT.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/PiFinder/focus.py | 303 ++++++++++++++++++++++++++++++++ python/PiFinder/ui/preview.py | 313 ++++++++++++++++++++++++++++------ python/tests/test_focus.py | 139 +++++++++++++++ 3 files changed, 706 insertions(+), 49 deletions(-) create mode 100644 python/PiFinder/focus.py create mode 100644 python/tests/test_focus.py diff --git a/python/PiFinder/focus.py b/python/PiFinder/focus.py new file mode 100644 index 000000000..ec24d6ef0 --- /dev/null +++ b/python/PiFinder/focus.py @@ -0,0 +1,303 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Self-contained focus-quality measurement for the Focus screen. + +This module implements a lightweight star detector and Half-Flux Diameter (HFD) +measurement that run in the main (UI) process on the raw 512x512 camera frame. +It is **deliberately independent** of the solver's tetra3/Cedar centroids and of +SQM photometry: a badly defocused frame does not plate-solve, so solver-derived +stars vanish at exactly the moment focus help is needed most. See +``docs/adr/0005-focus-hfd-self-contained-in-ui.md`` and the "Focus indicator" +section of ``docs/ax/ui/CONTEXT.md`` for the design rationale and vocabulary. + +The detector is tuned to *accept* broad/defocused blobs (the opposite of Cedar's +tight-star tuning), rejecting only single-pixel hot pixels and blobs too large to +measure usefully. All measurement is performed on the raw frame, never on the +display-stretched copy, so the reported HFD never depends on how the image looks. + +Pure numpy/scipy only -- no PIL/display or UIModule dependencies -- so it is +unit-testable against synthetic blobs of known width. +""" + +from dataclasses import dataclass +from typing import List, Optional, Tuple + +import numpy as np +from scipy import ndimage + + +@dataclass +class Blob: + """A detected star (broad or tight) in the raw frame. + + Attributes: + y, x: blob center in raw-frame pixel coordinates (numpy row, col). + peak: peak pixel value (ADU) inside the blob. + background: local background level (ADU/pixel) around the blob. + extent: largest bounding-box dimension in pixels (~ the blob diameter). + size_px: number of connected pixels above the detection threshold. + """ + + y: float + x: float + peak: float + background: float + extent: int + size_px: int + + +@dataclass +class FocusResult: + """Outcome of measuring focus on a single frame. + + Attributes: + median_hfd: median HFD (px) over the brightest detected stars, or None + when there is no usable star to measure. + n_used: number of detected stars HFD was measured on. + background: global background level (ADU) of the frame. + peak: brightest detected-star peak (ADU), or None when nothing detected. + Used as the white point for the display stretch. + too_defocused: True when there is clear signal but every blob is larger + than the size cap -- i.e. measurable stars exist but are too broad to + quantify. Drives the "keep adjusting" hint. + """ + + median_hfd: Optional[float] + n_used: int + background: float + peak: Optional[float] + too_defocused: bool + + +def _estimate_background_noise(np_image: np.ndarray) -> Tuple[float, float]: + """Return (background, noise_sigma) using robust median / MAD statistics. + + MAD (median absolute deviation) is insensitive to the bright star pixels and + to hot pixels, so the threshold tracks the sky floor rather than the signal. + A small floor on sigma avoids a zero threshold on a perfectly flat frame. + """ + background = float(np.median(np_image)) + mad = float(np.median(np.abs(np_image - background))) + sigma = 1.4826 * mad + return background, max(sigma, 1.0) + + +def _local_background(np_image: np.ndarray, cy: float, cx: float, extent: int) -> float: + """Median of an annulus around (cy, cx), sized from the blob extent. + + Independent re-implementation of the bbox + radial-distance patch geometry + also used by SQM (no shared code -- see ADR 0005). Falls back to the global + median if the annulus lands off-frame. + """ + height, width = np_image.shape + radius = max(extent, 4) + inner = radius + 2 + outer = radius + 8 + + y_min = max(0, int(cy) - outer) + y_max = min(height, int(cy) + outer + 1) + x_min = max(0, int(cx) - outer) + x_max = min(width, int(cx) + outer + 1) + + patch = np_image[y_min:y_max, x_min:x_max] + y_grid, x_grid = np.ogrid[y_min:y_max, x_min:x_max] + dist_sq = (x_grid - cx) ** 2 + (y_grid - cy) ** 2 + annulus = (dist_sq > inner**2) & (dist_sq <= outer**2) + + annulus_pixels = patch[annulus] + if annulus_pixels.size > 0: + return float(np.median(annulus_pixels)) + return float(np.median(np_image)) + + +def _find_blobs( + np_image: np.ndarray, + *, + max_blob_px: int, + sigma_k: float, +) -> Tuple[List[Blob], int, float, Optional[float]]: + """Label connected regions above the detection threshold. + + Returns (usable_blobs, n_oversized, background, brightest_peak) where + usable_blobs are blobs at least 2 px in size and no larger than the size cap, + sorted brightest-first. ``n_oversized`` counts blobs that exceed the size cap + (signal present but too defocused to measure). ``brightest_peak`` is the peak + of the brightest blob of any size, or None when nothing was detected. + """ + img = np.asarray(np_image, dtype=np.float32) + background, sigma = _estimate_background_noise(img) + threshold = background + sigma_k * sigma + + # Detect on a lightly smoothed copy so per-pixel noise does not fragment a + # broad defocused blob into many spurious tiny "stars" at its threshold ring + # (which would hide the too-defocused state). Measurement below still uses + # the raw frame -- see ADR 0005. + smoothed = ndimage.gaussian_filter(img, sigma=1.0) + mask = smoothed > threshold + labeled, n_labels = ndimage.label(mask) + if n_labels == 0: + return [], 0, background, None + + slices = ndimage.find_objects(labeled) + + usable: List[Blob] = [] + n_oversized = 0 + brightest_peak: Optional[float] = None + + for label_idx, sl in enumerate(slices, start=1): + if sl is None: + continue + region_mask = labeled[sl] == label_idx + + patch = img[sl] + peak = float(patch[region_mask].max()) + if brightest_peak is None or peak > brightest_peak: + brightest_peak = peak + + # Count pixels above threshold in the RAW frame (not the smoothed copy) + # so a single-pixel hot pixel -- which smoothing spreads into a small + # blob -- is still rejected as a one-pixel spike. + size_px = int(((patch > threshold) & region_mask).sum()) + if size_px < 2: + continue + + height = sl[0].stop - sl[0].start + width = sl[1].stop - sl[1].start + extent = int(max(height, width)) + + # Too broad to measure usefully -- treat as "too defocused". + if extent > max_blob_px: + n_oversized += 1 + continue + + cy = (sl[0].start + sl[0].stop - 1) / 2.0 + cx = (sl[1].start + sl[1].stop - 1) / 2.0 + local_bg = _local_background(img, cy, cx, extent) + + usable.append( + Blob( + y=cy, + x=cx, + peak=peak, + background=local_bg, + extent=extent, + size_px=size_px, + ) + ) + + usable.sort(key=lambda b: b.peak, reverse=True) + return usable, n_oversized, background, brightest_peak + + +def detect_stars( + np_image: np.ndarray, + *, + max_blob_px: int = 50, + sigma_k: float = 5.0, + n: int = 5, +) -> List[Blob]: + """Find up to ``n`` of the brightest usable blobs in the raw frame. + + Tuned to accept broad/defocused blobs but reject blobs larger than + ``max_blob_px`` (too defocused to measure) and single-pixel hot pixels. + Returned blobs are sorted brightest-first. + """ + usable, _, _, _ = _find_blobs(np_image, max_blob_px=max_blob_px, sigma_k=sigma_k) + return usable[:n] + + +def half_flux_diameter( + np_image: np.ndarray, + center: Tuple[float, float], + background: float, + *, + aperture_radius: int = 25, +) -> float: + """Half-Flux Diameter (px) for a single star centered at ``center`` (y, x). + + HFD = 2 * sum(flux_i * r_i) / sum(flux_i) over aperture pixels, where + flux_i = pixel_i - background clamped to >= 0. Stable on saturated cores and + broad defocused blobs, where a Gaussian (FWHM) fit fails. + """ + cy, cx = center + height, width = np_image.shape + + y_min = max(0, int(cy) - aperture_radius) + y_max = min(height, int(cy) + aperture_radius + 1) + x_min = max(0, int(cx) - aperture_radius) + x_max = min(width, int(cx) + aperture_radius + 1) + + patch = np.asarray(np_image[y_min:y_max, x_min:x_max], dtype=np.float32) + y_grid, x_grid = np.ogrid[y_min:y_max, x_min:x_max] + dist = np.sqrt((x_grid - cx) ** 2 + (y_grid - cy) ** 2) + aperture = dist <= aperture_radius + + flux = np.clip(patch - background, 0.0, None) + flux = np.where(aperture, flux, 0.0) + + total_flux = float(flux.sum()) + if total_flux <= 0.0: + return 0.0 + + weighted_r = float((flux * dist).sum()) + return 2.0 * weighted_r / total_flux + + +def focus_hfd( + np_image: np.ndarray, + *, + n: int = 5, + max_blob_px: int = 50, + sigma_k: float = 5.0, +) -> FocusResult: + """Measure focus on a single raw frame: detect -> measure -> median. + + Returns a :class:`FocusResult`. ``median_hfd`` is None when no usable star is + found; ``too_defocused`` is True when signal is present but every blob is + larger than ``max_blob_px``. + """ + usable, n_oversized, background, brightest_peak = _find_blobs( + np_image, max_blob_px=max_blob_px, sigma_k=sigma_k + ) + + if not usable: + # No measurable star. If oversized blobs exist there is signal, but the + # image is too defocused to quantify -> drive the "keep adjusting" hint. + return FocusResult( + median_hfd=None, + n_used=0, + background=background, + peak=brightest_peak, + too_defocused=n_oversized > 0, + ) + + img = np.asarray(np_image, dtype=np.float32) + hfds = [] + for blob in usable[:n]: + aperture_radius = int(np.clip(blob.extent, 10, max_blob_px)) + hfd = half_flux_diameter( + img, + (blob.y, blob.x), + blob.background, + aperture_radius=aperture_radius, + ) + if hfd > 0.0: + hfds.append(hfd) + + if not hfds: + return FocusResult( + median_hfd=None, + n_used=0, + background=background, + peak=brightest_peak, + too_defocused=n_oversized > 0, + ) + + return FocusResult( + median_hfd=float(np.median(hfds)), + n_used=len(hfds), + background=background, + peak=usable[0].peak, + too_defocused=False, + ) diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index bac4ce300..9a42edc19 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -9,18 +9,30 @@ """ import sys -import numpy as np import time +from collections import deque -from PIL import ImageChops, ImageOps +import numpy as np +from PIL import ImageChops +from PiFinder import focus, utils from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu -from PiFinder import utils from PiFinder.ui.base import UIModule from PiFinder.ui.ui_utils import outline_text sys.path.append(str(utils.tetra3_dir)) +# Focus indicator tuning (see docs/ax/ui/CONTEXT.md "Focus indicator" and +# docs/adr/0005-focus-hfd-self-contained-in-ui.md). Starting values -- adjust +# on real hardware. +FOCUS_WINDOW_S = 10.0 # rolling V-curve window +HFD_AXIS_MIN = 1.0 # log Y-axis bottom (px) +HFD_AXIS_MAX = 50.0 # log Y-axis top (px), matches detector size cap +PAST_BEST_RATIO = 1.20 # current HFD > marker * this -> past-best cue +CUE_SMOOTHING = 3 # samples to smooth the cue decision over +STRETCH_EMA_ALPHA = 0.3 # display-stretch black/white smoothing +STRETCH_MIN_SPAN = 25.0 # min ADU span so a starless frame stays near-black + class UIPreview(UIModule): from PiFinder import tetra3 @@ -46,8 +58,11 @@ def __init__(self, *args, **kwargs): self.star_list = np.empty((0, 2)) self.highlight_count = 0 - # Info overlay toggle (use square button) - self.show_info_overlay = False + # Focus indicator: strip on by default (square toggles it). Rolling + # state is (re)initialised in _reset_focus_state(), also called on + # active() so the V-curve clears each time the screen is entered. + self.show_focus_strip = True + self._reset_focus_state() # Marking menu definition self.marking_menu = MarkingMenu( @@ -59,6 +74,94 @@ def __init__(self, *args, **kwargs): right=MarkingMenuOption(), ) + def _reset_focus_state(self): + """Clear rolling focus-indicator state (history, stretch EMA, cue).""" + # (timestamp, hfd) samples over the rolling window; hfd is None for a + # frame with no usable star (a gap -- never carried forward). + self.focus_history: deque = deque() + self.last_focus_result = None + self._last_focus_frame_time = 0.0 + # Display-stretch black/white points (raw ADU), EMA-smoothed. + self._stretch_black = None + self._stretch_white = None + # Recent raw past-best decisions, smoothed for display. + self._cue_history: deque = deque(maxlen=CUE_SMOOTHING) + + def active(self): + """Reset the rolling focus history when the screen is entered.""" + self._reset_focus_state() + + def _measure_focus(self, raw_np): + """Run the self-contained HFD detector on a raw frame and update state. + + Appends a timestamped sample (HFD or None for a gap), prunes the rolling + window, updates the EMA display-stretch points, and records the smoothed + past-best cue decision. All measurement is on the raw frame. + """ + result = focus.focus_hfd(raw_np) + self.last_focus_result = result + now = time.time() + + self.focus_history.append((now, result.median_hfd)) + cutoff = now - FOCUS_WINDOW_S + while self.focus_history and self.focus_history[0][0] < cutoff: + self.focus_history.popleft() + + # Display stretch: black = background, white = brightest detected peak, + # with a minimum span so a starless frame stays near-black. + black = result.background + white = result.peak if result.peak is not None else black + STRETCH_MIN_SPAN + white = max(white, black + STRETCH_MIN_SPAN) + if self._stretch_black is None: + self._stretch_black, self._stretch_white = black, white + else: + a = STRETCH_EMA_ALPHA + self._stretch_black = a * black + (1 - a) * self._stretch_black + self._stretch_white = a * white + (1 - a) * self._stretch_white + + # Past-best cue raw decision: current HFD has risen well above the window + # minimum, and that minimum happened earlier (not on the latest sample). + cue_raw = False + marker, marker_ts = self._focus_marker() + if ( + result.median_hfd is not None + and marker is not None + and marker_ts is not None + and result.median_hfd > marker * PAST_BEST_RATIO + and marker_ts < now + ): + cue_raw = True + self._cue_history.append(cue_raw) + + def _focus_marker(self): + """Return (min_hfd, timestamp_of_min) over the window, or (None, None).""" + samples = [(t, h) for (t, h) in self.focus_history if h is not None] + if not samples: + return None, None + ts, hfd = min(samples, key=lambda s: s[1]) + return hfd, ts + + def _cue_active(self): + """Smoothed past-best cue: majority of recent raw decisions are True.""" + if not self._cue_history: + return False + return sum(self._cue_history) * 2 >= len(self._cue_history) + + def _apply_stretch(self, image_obj): + """Background-anchored linear stretch of a mode-'L' image (cosmetic). + + Replaces per-frame autocontrast: black/white points come from the + detector's EMA-smoothed background/peak, so the stretch is stable and a + starless frame does not get its noise amplified. + """ + if self._stretch_black is None or self._stretch_white is None: + return image_obj + black = self._stretch_black + span = max(self._stretch_white - black, 1.0) + scale = 255.0 / span + lut = [min(255, max(0, int((i - black) * scale))) for i in range(256)] + return image_obj.point(lut) + def draw_reticle(self): """ draw the reticle if desired @@ -149,68 +252,175 @@ def format_exposure_display(self) -> str: pass return "N/A" - def draw_info_overlay(self): - """Draw info overlay with exposure time and star count.""" - if not self.show_info_overlay: - return - - # Get exposure info - exposure_text = self.format_exposure_display() + def _matched_star_text(self): + """Recent matched-star count (the solver's catalog matches), or '-'. - # Get star count from solution (only if recent) - star_count_text = "---" + Its 0 -> N jump signals "sharp enough to solve"; kept alongside the + self-contained detected-star count. + """ try: solution = self.shared_state.solution() solve_source = solution.get("solve_source") if solution else None solve_time = solution.get("solve_time") if solution else None - - # Show star count only for recent camera solves (within last 10 seconds) if solve_source in ("CAM", "CAM_FAILED") and solve_time: if time.time() - solve_time < 10: - matched_stars = solution.get("Matches", 0) - star_count_text = str(matched_stars) + return str(solution.get("Matches", 0)) except Exception: pass + return "-" + + def _hfd_to_y(self, hfd, plot_top, plot_bottom): + """Map an HFD value to a screen y on the fixed log axis (low = bottom).""" + clamped = min(max(hfd, HFD_AXIS_MIN), HFD_AXIS_MAX) + norm = np.log(clamped / HFD_AXIS_MIN) / np.log(HFD_AXIS_MAX / HFD_AXIS_MIN) + return int(plot_bottom - norm * (plot_bottom - plot_top)) - # Position below title bar (titlebar_height is typically 17) - y_offset = self.display_class.titlebar_height + 2 + def draw_focus_strip(self): + """Render the focus strip: V-curve, marker, past-best cue, and HUD. + + ~38 px bottom band, on by default; square hides it. Persists across all + zoom levels (HFD is zoom-independent). + """ + strip_top = 90 + res_x = self.display_class.resX + res_y = self.display_class.resY + plot_top = strip_top + 11 + plot_bottom = res_y - 11 + plot_left = 2 + plot_right = res_x - 3 + + # Dim band so the overlay stays legible over a bright image. + self.draw.rectangle([0, strip_top, res_x, res_y], fill=(0, 0, 0, 150)) + + bright = self.colors.get(255) + medium = self.colors.get(128) + dim = self.colors.get(64) + + # --- V-curve + best-focus marker over the rolling window --- + now = time.time() + window_start = now - FOCUS_WINDOW_S + span = plot_right - plot_left + + def x_of(ts): + frac = (ts - window_start) / FOCUS_WINDOW_S + return int(plot_left + min(max(frac, 0.0), 1.0) * span) + + marker, _marker_ts = self._focus_marker() + if marker is not None: + marker_y = self._hfd_to_y(marker, plot_top, plot_bottom) + self.draw.line([(plot_left, marker_y), (plot_right, marker_y)], fill=dim) + + prev = None + for ts, hfd in self.focus_history: + if hfd is None: + prev = None # gap -- break the line + continue + point = (x_of(ts), self._hfd_to_y(hfd, plot_top, plot_bottom)) + if prev is not None: + self.draw.line([prev, point], fill=bright) + else: + self.draw.point(point, fill=bright) + prev = point + + # --- HUD text --- + result = self.last_focus_result + if result is not None and result.median_hfd is not None: + hfd_text = f"HFD {result.median_hfd:.1f}" + detected = str(result.n_used) + elif result is not None and result.too_defocused: + hfd_text = _("keep adjusting…") + detected = "0" + else: + hfd_text = "HFD —" + detected = "0" + + cue = self._cue_active() + hfd_fill = bright if cue else medium + if cue: + hfd_text = f"↑ {hfd_text}" # up-arrow: back up toward best focus - # Draw exposure text with black outline using utility function outline_text( self.draw, - (2, y_offset), - exposure_text, + (2, strip_top), + hfd_text, align="left", - font=self.fonts.bold, - fill=(192, 0, 0), # Medium bright red - shadow_color=(0, 0, 0), # Black outline + font=self.fonts.small, + fill=hfd_fill, + shadow_color=(0, 0, 0), stroke=1, ) + outline_text( + self.draw, + (res_x - 2, strip_top), + self.format_exposure_display(), + align="left", + font=self.fonts.small, + fill=medium, + shadow_color=(0, 0, 0), + stroke=1, + anchor="ra", + ) - # Draw star count with NerdFont icon - right-aligned to prevent jitter - stars_text = f"{self._STAR_ICON} {star_count_text}" - + # Bottom row: detected-star count (left) and matched-star count (right). + bottom_y = res_y - 9 + outline_text( + self.draw, + (2, bottom_y), + _("det {n}").format(n=detected), + align="left", + font=self.fonts.small, + fill=medium, + shadow_color=(0, 0, 0), + stroke=1, + ) + if cue: + outline_text( + self.draw, + (res_x // 2, bottom_y), + _("BACK UP"), + align="center", + font=self.fonts.small, + fill=bright, + shadow_color=(0, 0, 0), + stroke=1, + anchor="ma", + ) outline_text( self.draw, - (126, y_offset), - stars_text, + (res_x - 2, bottom_y), + f"{self._STAR_ICON} {self._matched_star_text()}", align="left", - font=self.fonts.bold, - fill=(192, 0, 0), # Medium bright red - shadow_color=(0, 0, 0), # Black outline + font=self.fonts.small, + fill=medium, + shadow_color=(0, 0, 0), stroke=1, - anchor="ra", # Right-anchor: right edge at x=126 + anchor="ra", ) def update(self, force=False): if force: self.last_update = 0 # display an image - last_image_time = self.shared_state.last_image_metadata()["exposure_end"] + metadata = self.shared_state.last_image_metadata() + last_image_time = metadata["exposure_end"] image_updated = False if last_image_time > self.last_update: image_updated = True - image_obj = self.camera_image.copy() + # camera_image is a multiprocessing-manager proxy; .copy() returns a + # real PIL Image. Copy once, measure on the raw 512x512 frame, then + # reuse the same copy for the (zoomed) display transform. + raw_image = self.camera_image.copy() + + # Measure focus on the RAW frame before any display transform, and + # only for a genuinely new frame (not a forced redraw). + new_frame = last_image_time != self._last_focus_frame_time + if new_frame: + # focus_hfd needs a 2D array; convert to luminance so it works + # for both mode-"L" hardware frames and RGB debug frames. + self._measure_focus(np.asarray(raw_image.convert("L"))) + self._last_focus_frame_time = last_image_time + + image_obj = raw_image # Resize if self.zoom_level == 0: @@ -222,29 +432,34 @@ def update(self, force=False): # no resize, just crop image_obj = image_obj.crop((192, 192, 320, 320)) - # Convert to RED + # Background-anchored linear stretch (replaces autocontrast), then RED. + # Stretch on a single-band image so the 256-entry LUT applies cleanly + # (debug frames are RGB; hardware frames are already mode "L"). + image_obj = image_obj.convert("L") + image_obj = self._apply_stretch(image_obj) image_obj = image_obj.convert("RGB") image_obj = ImageChops.multiply(image_obj, self.colors.red_image) - image_obj = ImageOps.autocontrast(image_obj) self.screen.paste(image_obj) self.last_update = last_image_time + if self.zoom_level == 0: + self.draw_reticle() + + # Image paste cleared the screen, so redraw overlays after a paste. + if image_updated or force: if self.zoom_level > 0: + # Zoom label relocated out of the focus-strip area (top-left, + # just under the titlebar). zoom_number = self.zoom_level * 2 self.draw.text( - (75, 112), + (2, self.display_class.titlebar_height + 1), _("Zoom x{zoom_number}").format(zoom_number=zoom_number), font=self.fonts.bold.font, fill=self.colors.get(128), ) - else: - self.draw_reticle() - - # Draw info overlay if enabled and image was updated - # (image paste cleared the screen, so we need to redraw overlay) - if image_updated or force: - self.draw_info_overlay() + if self.show_focus_strip: + self.draw_focus_strip() return self.screen_update() @@ -259,6 +474,6 @@ def key_minus(self): self.zoom_level = 0 def key_square(self): - """Toggle info overlay on/off with square button.""" - self.show_info_overlay = not self.show_info_overlay + """Toggle the focus strip (V-curve + HUD) on/off with the square button.""" + self.show_focus_strip = not self.show_focus_strip self.update(force=True) diff --git a/python/tests/test_focus.py b/python/tests/test_focus.py new file mode 100644 index 000000000..cb730a454 --- /dev/null +++ b/python/tests/test_focus.py @@ -0,0 +1,139 @@ +"""Unit tests for PiFinder.focus -- the self-contained focus HFD detector. + +See docs/adr/0005-focus-hfd-self-contained-in-ui.md for the design rationale. +""" + +import numpy as np +import pytest + +from PiFinder import focus + + +def _gaussian_frame( + sigma, + *, + size=512, + amplitude=200.0, + background=20.0, + center=(256, 256), + noise=1.0, + seed=42, +): + """Render a single 2D-Gaussian star on a (optionally noisy) background.""" + rng = np.random.default_rng(seed) + if noise > 0: + img = rng.normal(background, noise, (size, size)).astype(np.float32) + else: + img = np.full((size, size), background, dtype=np.float32) + cy, cx = center + y, x = np.ogrid[:size, :size] + img += amplitude * np.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (2 * sigma**2)) + return np.clip(img, 0, 255) + + +@pytest.mark.unit +class TestHalfFluxDiameter: + def test_gaussian_hfd_matches_theory(self): + """For a 2D Gaussian, HFD = 2 * E[r] = 2 * sigma * sqrt(pi/2) ~ 2.5066 sigma.""" + sigma = 4.0 + img = _gaussian_frame(sigma, amplitude=200.0, background=0.0, noise=0.0) + hfd = focus.half_flux_diameter(img, (256, 256), 0.0, aperture_radius=40) + expected = 2.0 * sigma * np.sqrt(np.pi / 2.0) + assert hfd == pytest.approx(expected, rel=0.12) + + def test_monotonic_in_width(self): + """Wider blob -> larger HFD.""" + hfds = [] + for sigma in (2.0, 4.0, 8.0): + img = _gaussian_frame(sigma, amplitude=200.0, background=0.0, noise=0.0) + hfds.append( + focus.half_flux_diameter(img, (256, 256), 0.0, aperture_radius=45) + ) + assert hfds[0] < hfds[1] < hfds[2] + + def test_saturated_core_is_finite(self): + """A saturated (clipped) core must still yield a finite, stable HFD.""" + img = _gaussian_frame( + 5.0, amplitude=1000.0, background=10.0, noise=0.0 + ) # clips at 255 + assert np.max(img) == pytest.approx(255.0) + hfd = focus.half_flux_diameter(img, (256, 256), 10.0, aperture_radius=40) + assert np.isfinite(hfd) + assert hfd > 0.0 + + def test_no_flux_returns_zero(self): + img = np.full((128, 128), 30.0, dtype=np.float32) + hfd = focus.half_flux_diameter(img, (64, 64), 30.0, aperture_radius=20) + assert hfd == 0.0 + + +@pytest.mark.unit +class TestDetectStars: + def test_finds_a_clear_star(self): + img = _gaussian_frame(4.0, amplitude=180.0, background=20.0) + blobs = focus.detect_stars(img) + assert len(blobs) >= 1 + brightest = blobs[0] + assert brightest.y == pytest.approx(256, abs=3) + assert brightest.x == pytest.approx(256, abs=3) + + def test_rejects_hot_pixel(self): + img = np.full((128, 128), 20.0, dtype=np.float32) + img[64, 64] = 255.0 # single-pixel spike + blobs = focus.detect_stars(img) + assert blobs == [] + + def test_returns_at_most_n(self): + img = np.random.default_rng(1).normal(20.0, 1.0, (512, 512)).astype(np.float32) + y, x = np.ogrid[:512, :512] + for i, (cy, cx) in enumerate( + [(100, 100), (100, 400), (400, 100), (400, 400), (256, 256), (256, 100)] + ): + img += (150 + 10 * i) * np.exp( + -((x - cx) ** 2 + (y - cy) ** 2) / (2 * 3.0**2) + ) + img = np.clip(img, 0, 255) + blobs = focus.detect_stars(img, n=3) + assert len(blobs) == 3 + + +@pytest.mark.unit +class TestFocusHfd: + def test_blank_frame_returns_none(self): + img = np.random.default_rng(7).normal(20.0, 1.0, (512, 512)).astype(np.float32) + result = focus.focus_hfd(img) + assert result.median_hfd is None + assert result.n_used == 0 + assert result.too_defocused is False + + def test_oversized_blob_is_too_defocused(self): + """A blob broader than the size cap is not measured -> too_defocused.""" + img = _gaussian_frame(40.0, amplitude=200.0, background=20.0) + result = focus.focus_hfd(img, max_blob_px=50) + assert result.median_hfd is None + assert result.n_used == 0 + assert result.too_defocused is True + + def test_measures_clear_star(self): + img = _gaussian_frame(4.0, amplitude=200.0, background=20.0) + result = focus.focus_hfd(img) + assert result.median_hfd is not None + assert result.n_used >= 1 + assert result.too_defocused is False + expected = 2.0 * 4.0 * np.sqrt(np.pi / 2.0) + assert result.median_hfd == pytest.approx(expected, rel=0.25) + + def test_median_robust_to_outlier(self): + """One fat star among several tight ones should not skew the median.""" + rng = np.random.default_rng(3) + img = rng.normal(20.0, 1.0, (512, 512)).astype(np.float32) + y, x = np.ogrid[:512, :512] + tight = [(100, 100), (100, 400), (400, 100), (400, 400)] + for cy, cx in tight: + img += 180 * np.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (2 * 3.0**2)) + # one broad-but-still-measurable outlier + img += 200 * np.exp(-((x - 256) ** 2 + (y - 256) ** 2) / (2 * 12.0**2)) + img = np.clip(img, 0, 255) + result = focus.focus_hfd(img, n=5) + tight_hfd = 2.0 * 3.0 * np.sqrt(np.pi / 2.0) + assert result.median_hfd == pytest.approx(tight_hfd, rel=0.4) From 3bed31b6c5cf0a405673ebe9a78f8d2d026f091a Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 29 May 2026 11:34:19 -0700 Subject: [PATCH 2/4] Docs: document the focus-quality indicator in the Quick Start Reframe "Setting Focus & First Solve" around the HFD readout and its V-curve rather than zooming in to judge stars by eye. Add a focus-strip walkthrough and HUD example images (focused vs unfocused, with the strip composited over the existing example frames). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../images/quick_start/CAMERA_focused_hud.png | Bin 0 -> 10621 bytes .../quick_start/CAMERA_unfocused_hud.png | Bin 0 -> 10597 bytes .../images/quick_start/focus_strip_docs.png | Bin 0 -> 15026 bytes docs/source/quick_start.rst | 48 +++++++++++++----- 4 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 docs/source/images/quick_start/CAMERA_focused_hud.png create mode 100644 docs/source/images/quick_start/CAMERA_unfocused_hud.png create mode 100644 docs/source/images/quick_start/focus_strip_docs.png diff --git a/docs/source/images/quick_start/CAMERA_focused_hud.png b/docs/source/images/quick_start/CAMERA_focused_hud.png new file mode 100644 index 0000000000000000000000000000000000000000..597f7e2f8c871346ba8eba557c320ecbb72a4545 GIT binary patch literal 10621 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/quick_start/focus_strip_docs.png b/docs/source/images/quick_start/focus_strip_docs.png new file mode 100644 index 0000000000000000000000000000000000000000..2dac04210a88d7d4002b79e6a7e458c7b0f17bb9 GIT binary patch literal 15026 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 A^#A|> literal 0 HcmV?d00001 diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index 6cddc4a38..8fca0a53d 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -271,9 +271,9 @@ some adjustment to see the dimmest stars. .. note:: **Focus is the single most common reason a PiFinder won't solve.** Stars that look - perfectly sharp at the normal zoom level are often not tight enough, so always zoom in - to 2x and 4x with the **+/-** keys and keep adjusting until the stars are as small as - you can make them. + sharp by eye are often still too soft to solve, so rather than judging focus by sight, + use the **HFD** readout and its graph on the Focus screen (described below) to find the + sharpest point precisely. Screw the lens in and out in the holder to adjust focus if needed. If you're starting from scratch — a new build, or a lens that's been moved — set the lens so about 6mm of @@ -292,29 +292,49 @@ have a noisy appearance, this is normal until the camera is near focus. See bel .. list-table:: - * - .. figure:: images/quick_start/CAMERA_unfocused.png + * - .. figure:: images/quick_start/CAMERA_unfocused_hud.png - Unfocused star with bright background + Unfocused: bright, noisy background and a high HFD - - .. figure:: images/quick_start/CAMERA_focused.png + - .. figure:: images/quick_start/CAMERA_focused_hud.png - Tightly focused star with darkened background + At best focus: dark background, a tight star and a low HFD -Try to pan your scope until you see some bright object in the camera view. You can screw the lens in and out -to adjust focus. Once something star-like is in the FOV and near focus, the image processing in the preview screen +Try to pan your scope until you see some bright object in the camera view. You can screw the lens in and out +to adjust focus. Once something star-like is in the FOV and near focus, the image processing in the preview screen will work properly and start dimming the background and highlighting the stars. -Good focus is important for the quickest solves. Close will work, but you should use the **+/-** keys to zoom the view to 2x and 4x -to get the stars as tight as you reasonably can. If the sky is dark enough and you've got focus -correct, you should see the camera icon appear in the top right and the current constellation will be shown in +Along the bottom of the Focus screen is the **focus strip**, which turns focusing from a judgement +call into a number you can chase. At the left it reports **HFD** — the Half-Flux Diameter of the +stars it finds, measured in pixels. This is simply how spread-out the stars are, so a smaller +number means tighter, sharper stars: as you adjust the lens, your goal is to make the HFD as small +as you can. + +.. image:: images/quick_start/focus_strip_docs.png + +The graph in the strip plots the HFD over the last several seconds. As you slowly turn the focuser +the line traces a "V" — dropping as the stars sharpen, reaching a low point at best focus, then +climbing again as you go past it. Stop at the bottom of the V. A marker line shows the best (lowest) +HFD seen recently, and if you overshoot, the strip flashes **BACK UP** with an up arrow so you know +to reverse direction. The readout also shows the current exposure time, **det** (the number of stars +the focus screen detected) and, once a solve succeeds, the matched-star count next to the star icon — +watch that jump from zero the moment your stars are sharp enough for the PiFinder to recognise them. + +If the image is too far out of focus to measure, the HFD reads ``keep adjusting…`` until a star comes +into range. The strip works at every zoom level, since the HFD doesn't depend on zoom, and you can +press the **SQUARE** button to hide or show it if you'd rather see the bare image. + +Good focus is important for the quickest solves. Close will work, but it's worth taking a moment to drive the +HFD down to its lowest point. If the sky is dark enough and you've got focus +correct, you should see the camera icon appear in the top right and the current constellation will be shown in the title bar. Congratulations, the PiFinder knows where it is pointing! .. note:: **Can’t get a plate solve?** Check to make sure your lens cap is off, the PiFinder is not moving and - the lens is properly focused — remember to zoom to 2x and 4x to judge focus, as soft stars are - the usual culprit. + the lens is properly focused — soft stars are the usual culprit, so check the HFD on the Focus + screen and adjust the lens until it reaches its lowest value. **Still not working?** Make sure nothing is impeding PiFinder’s view of the sky, and its lens has not dewed or fogged over. A bank of high, thin cloud drifting through can also stop From 78df1d6fc153f52ebe523061f2b35b2c99821981 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 2 Jun 2026 19:45:19 -0700 Subject: [PATCH 3/4] =?UTF-8?q?Focus=20strip:=20big=20HFD=20readout,=204?= =?UTF-8?q?=E2=80=9320px=20axis,=20gentler=20dithered=20stretch,=20drop=20?= =?UTF-8?q?back-up=20cue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HFD readout is now a large, right-justified number filling the strip height (the hero element); the V-curve and small labels move to the freed left region. The no-reading states fall back to a small dim hint ("—" / "keep going") instead of a giant placeholder glyph. - V-curve axis is 4 → 20 px (was 1 → 50): ~4 px is about the best a real camera/lens hits and ~20 px is clearly soft, so the trend spends its range where it's useful. Out-of-range readings clamp; the numeric readout is exact. - Display stretch is calmer: lower EMA alpha + larger minimum span cut the frame-to-frame brightness swings, and a little uniform dither breaks the 8-bit banding a narrow stretch otherwise posterised into. - Remove the past-best "BACK UP" cue and its supporting machinery. - Update the Quick Start prose and the UI CONTEXT glossary to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ax/ui/CONTEXT.md | 6 +- docs/source/quick_start.rst | 25 ++-- python/PiFinder/ui/preview.py | 226 ++++++++++++++++------------------ 3 files changed, 123 insertions(+), 134 deletions(-) diff --git a/docs/ax/ui/CONTEXT.md b/docs/ax/ui/CONTEXT.md index f1735c4c5..f789e89e9 100644 --- a/docs/ax/ui/CONTEXT.md +++ b/docs/ax/ui/CONTEXT.md @@ -137,7 +137,7 @@ The **median** HFD over the few brightest detected stars in the current frame _Avoid_: best HFD (that's the marker), single-star HFD. **Focus strip**: -The bottom-of-screen overlay (~30 px) that renders the focus indicator over the live image: the V-curve, best-focus marker, past-best cue, focus HFD readout, exposure, detected-star count, and the (kept) matched-star count. On by default; `square` hides the whole strip. Persists across all zoom levels (HFD is zoom-independent). +The bottom-of-screen overlay (~38 px) that renders the focus indicator over the live image: a large right-justified **focus HFD** readout (filling the strip height), and in the freed left region the V-curve, best-focus marker, exposure, detected-star count, and the (kept) matched-star count. On by default; `square` hides the whole strip. Persists across all zoom levels (HFD is zoom-independent). _Avoid_: HUD (loosely the same overlay; "focus strip" is the canonical name), info overlay (the prior exposure+matched-count overlay this replaces). **V-curve** (focus trend graph): @@ -148,10 +148,6 @@ _Avoid_: focus graph, history graph, trend line. The minimum focus HFD within the rolling 10-second window — the bottom of the current V. Auto-rearms as old samples scroll out of the window; there is no manual reset. _Avoid_: best focus (the state), minimum line, target HFD. -**Past-best cue**: -The explicit "you've passed best focus — back up" signal. Fires when the current focus HFD exceeds `best-focus marker × (1 + threshold)` and that minimum occurred earlier in the window. -_Avoid_: overshoot warning, back-up arrow, alert. - ## Boundary terms - **`shared_state`** is read and written by the UI but **owned by Positioning**. See [Positioning](../positioning/CONTEXT.md). The UI publishes the screen image and UI-state dict onto it; it reads `solution()`, `imu()`, `sqm()`, `location()`, `altaz_ready()`. diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index 8fca0a53d..d4b288764 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -306,22 +306,23 @@ to adjust focus. Once something star-like is in the FOV and near focus, the ima will work properly and start dimming the background and highlighting the stars. Along the bottom of the Focus screen is the **focus strip**, which turns focusing from a judgement -call into a number you can chase. At the left it reports **HFD** — the Half-Flux Diameter of the -stars it finds, measured in pixels. This is simply how spread-out the stars are, so a smaller -number means tighter, sharper stars: as you adjust the lens, your goal is to make the HFD as small -as you can. +call into a number you can chase. A large **HFD** readout — the Half-Flux Diameter of the stars it +finds, in pixels — fills the right of the strip. This is simply how spread-out the stars are, so a +smaller number means tighter, sharper stars: as you adjust the lens, your goal is to make the HFD as +small as you can. .. image:: images/quick_start/focus_strip_docs.png -The graph in the strip plots the HFD over the last several seconds. As you slowly turn the focuser -the line traces a "V" — dropping as the stars sharpen, reaching a low point at best focus, then -climbing again as you go past it. Stop at the bottom of the V. A marker line shows the best (lowest) -HFD seen recently, and if you overshoot, the strip flashes **BACK UP** with an up arrow so you know -to reverse direction. The readout also shows the current exposure time, **det** (the number of stars -the focus screen detected) and, once a solve succeeds, the matched-star count next to the star icon — -watch that jump from zero the moment your stars are sharp enough for the PiFinder to recognise them. +To the left of the readout a graph plots the HFD over the last several seconds. As you slowly turn +the focuser the line traces a "V" — dropping as the stars sharpen, reaching a low point at best focus, +then climbing again as you go past it. Stop at the bottom of the V. The graph is scaled to the range +a real lens reaches — about 4 px at sharp focus up to 20 px when clearly soft — and a marker line shows +the best (lowest) HFD seen recently. Small labels show the current exposure time, **det** (the number +of stars the focus screen detected) and, once a solve succeeds, the matched-star count next to the star +icon — watch that jump from zero the moment your stars are sharp enough for the PiFinder to recognise +them. -If the image is too far out of focus to measure, the HFD reads ``keep adjusting…`` until a star comes +If the image is too far out of focus to measure, the readout shows ``keep going`` until a star comes into range. The strip works at every zoom level, since the HFD doesn't depend on zoom, and you can press the **SQUARE** button to hide or show it if you'd rather see the bare image. diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index 9a42edc19..e14e468a9 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -13,7 +13,7 @@ from collections import deque import numpy as np -from PIL import ImageChops +from PIL import Image, ImageChops from PiFinder import focus, utils from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu @@ -26,12 +26,16 @@ # docs/adr/0005-focus-hfd-self-contained-in-ui.md). Starting values -- adjust # on real hardware. FOCUS_WINDOW_S = 10.0 # rolling V-curve window -HFD_AXIS_MIN = 1.0 # log Y-axis bottom (px) -HFD_AXIS_MAX = 50.0 # log Y-axis top (px), matches detector size cap -PAST_BEST_RATIO = 1.20 # current HFD > marker * this -> past-best cue -CUE_SMOOTHING = 3 # samples to smooth the cue decision over -STRETCH_EMA_ALPHA = 0.3 # display-stretch black/white smoothing -STRETCH_MIN_SPAN = 25.0 # min ADU span so a starless frame stays near-black +# V-curve axis: 4 px is about the best a real camera/lens can hit, so it anchors +# the bottom; 20 px is "clearly defocused". Readings outside [4, 20] clamp to the +# axis ends (the big numeric readout still shows the true value). +HFD_AXIS_MIN = 4.0 # log Y-axis bottom (px) -- best achievable focus +HFD_AXIS_MAX = 20.0 # log Y-axis top (px) -- clearly defocused +# Display-stretch: smaller alpha + larger min span keep the preview calm (the +# stretch was over-reacting frame to frame); the dither breaks 8-bit banding. +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 class UIPreview(UIModule): @@ -75,7 +79,7 @@ def __init__(self, *args, **kwargs): ) def _reset_focus_state(self): - """Clear rolling focus-indicator state (history, stretch EMA, cue).""" + """Clear rolling focus-indicator state (history, stretch EMA points).""" # (timestamp, hfd) samples over the rolling window; hfd is None for a # frame with no usable star (a gap -- never carried forward). self.focus_history: deque = deque() @@ -84,8 +88,6 @@ def _reset_focus_state(self): # Display-stretch black/white points (raw ADU), EMA-smoothed. self._stretch_black = None self._stretch_white = None - # Recent raw past-best decisions, smoothed for display. - self._cue_history: deque = deque(maxlen=CUE_SMOOTHING) def active(self): """Reset the rolling focus history when the screen is entered.""" @@ -95,8 +97,8 @@ def _measure_focus(self, raw_np): """Run the self-contained HFD detector on a raw frame and update state. Appends a timestamped sample (HFD or None for a gap), prunes the rolling - window, updates the EMA display-stretch points, and records the smoothed - past-best cue decision. All measurement is on the raw frame. + window, and updates the EMA display-stretch points. All measurement is on + the raw frame. """ result = focus.focus_hfd(raw_np) self.last_focus_result = result @@ -119,48 +121,36 @@ def _measure_focus(self, raw_np): self._stretch_black = a * black + (1 - a) * self._stretch_black self._stretch_white = a * white + (1 - a) * self._stretch_white - # Past-best cue raw decision: current HFD has risen well above the window - # minimum, and that minimum happened earlier (not on the latest sample). - cue_raw = False - marker, marker_ts = self._focus_marker() - if ( - result.median_hfd is not None - and marker is not None - and marker_ts is not None - and result.median_hfd > marker * PAST_BEST_RATIO - and marker_ts < now - ): - cue_raw = True - self._cue_history.append(cue_raw) - def _focus_marker(self): - """Return (min_hfd, timestamp_of_min) over the window, or (None, None).""" - samples = [(t, h) for (t, h) in self.focus_history if h is not None] - if not samples: - return None, None - ts, hfd = min(samples, key=lambda s: s[1]) - return hfd, ts - - def _cue_active(self): - """Smoothed past-best cue: majority of recent raw decisions are True.""" - if not self._cue_history: - return False - return sum(self._cue_history) * 2 >= len(self._cue_history) + """Return the best (min) HFD over the window, or None if no samples.""" + samples = [h for (_t, h) in self.focus_history if h is not None] + return min(samples) if samples else None def _apply_stretch(self, image_obj): """Background-anchored linear stretch of a mode-'L' image (cosmetic). Replaces per-frame autocontrast: black/white points come from the detector's EMA-smoothed background/peak, so the stretch is stable and a - starless frame does not get its noise amplified. + starless frame does not get its noise amplified. The minimum span keeps + a faint frame from being stretched hard, and a little uniform dither is + added before quantising back to 8-bit so a narrow stretch doesn't band + into visible contour steps. Cosmetic only -- HFD is measured on the raw + frame, never on this. """ if self._stretch_black is None or self._stretch_white is None: return image_obj black = self._stretch_black - span = max(self._stretch_white - black, 1.0) + span = max(self._stretch_white - black, STRETCH_MIN_SPAN) scale = 255.0 / span - lut = [min(255, max(0, int((i - black) * scale))) for i in range(256)] - return image_obj.point(lut) + + arr = np.asarray(image_obj, dtype=np.float32) + stretched = (arr - black) * scale + # Uniform dither, peak-to-peak ~ one output step, so a narrow stretch + # blends across band boundaries instead of posterising into contours. + dither = scale * STRETCH_DITHER_FRAC + stretched += np.random.uniform(-dither, dither, size=arr.shape) + np.clip(stretched, 0, 255, out=stretched) + return Image.fromarray(stretched.astype(np.uint8), mode="L") def draw_reticle(self): """ @@ -276,18 +266,16 @@ def _hfd_to_y(self, hfd, plot_top, plot_bottom): return int(plot_bottom - norm * (plot_bottom - plot_top)) def draw_focus_strip(self): - """Render the focus strip: V-curve, marker, past-best cue, and HUD. + """Render the focus strip: big HFD readout, V-curve, marker, and HUD. ~38 px bottom band, on by default; square hides it. Persists across all - zoom levels (HFD is zoom-independent). + zoom levels (HFD is zoom-independent). Layout: a large right-justified + HFD number (the hero readout) fills the strip height; the V-curve and + small labels sit in the freed left region. """ strip_top = 90 res_x = self.display_class.resX res_y = self.display_class.resY - plot_top = strip_top + 11 - plot_bottom = res_y - 11 - plot_left = 2 - plot_right = res_x - 3 # Dim band so the overlay stays legible over a bright image. self.draw.rectangle([0, strip_top, res_x, res_y], fill=(0, 0, 0, 150)) @@ -296,63 +284,65 @@ def draw_focus_strip(self): medium = self.colors.get(128) dim = self.colors.get(64) - # --- V-curve + best-focus marker over the rolling window --- - now = time.time() - window_start = now - FOCUS_WINDOW_S - span = plot_right - plot_left - - def x_of(ts): - frac = (ts - window_start) / FOCUS_WINDOW_S - return int(plot_left + min(max(frac, 0.0), 1.0) * span) - - marker, _marker_ts = self._focus_marker() - if marker is not None: - marker_y = self._hfd_to_y(marker, plot_top, plot_bottom) - self.draw.line([(plot_left, marker_y), (plot_right, marker_y)], fill=dim) - - prev = None - for ts, hfd in self.focus_history: - if hfd is None: - prev = None # gap -- break the line - continue - point = (x_of(ts), self._hfd_to_y(hfd, plot_top, plot_bottom)) - if prev is not None: - self.draw.line([prev, point], fill=bright) - else: - self.draw.point(point, fill=bright) - prev = point - - # --- HUD text --- result = self.last_focus_result + detected = str(result.n_used) if result is not None else "0" + + # --- HFD readout: right-justified in a fixed-width slot so the V-curve's + # right edge never shifts as the value changes. A real reading is the big + # hero number (filling the strip height); the no-reading states fall back + # to a small dim hint rather than a giant placeholder glyph. --- + big_font = self.fonts.huge + slot_w = int(self.draw.textlength("00.0", font=big_font.font)) + num_right = res_x - 2 + num_left = num_right - slot_w + num_mid_y = (strip_top + res_y) // 2 - 1 + if result is not None and result.median_hfd is not None: - hfd_text = f"HFD {result.median_hfd:.1f}" - detected = str(result.n_used) - elif result is not None and result.too_defocused: - hfd_text = _("keep adjusting…") - detected = "0" + self.draw.text( + (num_right, num_mid_y), + f"{result.median_hfd:.1f}", + font=big_font.font, + fill=bright, + anchor="rm", + ) else: - hfd_text = "HFD —" - detected = "0" + # too_defocused = a star is there but too broad to measure (keep + # adjusting toward focus); otherwise nothing usable was found. + hint = _("keep going") if (result and result.too_defocused) else "—" + outline_text( + self.draw, + (num_right, num_mid_y), + hint, + align="right", + font=self.fonts.base, + fill=dim, + shadow_color=(0, 0, 0), + stroke=1, + anchor="rm", + ) - cue = self._cue_active() - hfd_fill = bright if cue else medium - if cue: - hfd_text = f"↑ {hfd_text}" # up-arrow: back up toward best focus + # --- Left region: V-curve framed by small labels --- + plot_left = 2 + plot_right = num_left - 3 + plot_top = strip_top + 9 + plot_bottom = res_y - 10 + # Top labels: exposure (left), matched-star count (right of the graph). + # The matched 0 -> N jump still signals "sharp enough to solve". outline_text( self.draw, - (2, strip_top), - hfd_text, + (plot_left, strip_top), + self.format_exposure_display(), align="left", font=self.fonts.small, - fill=hfd_fill, + fill=medium, shadow_color=(0, 0, 0), stroke=1, ) outline_text( self.draw, - (res_x - 2, strip_top), - self.format_exposure_display(), + (plot_right, strip_top), + f"{self._STAR_ICON}{self._matched_star_text()}", align="left", font=self.fonts.small, fill=medium, @@ -361,11 +351,10 @@ def x_of(ts): anchor="ra", ) - # Bottom row: detected-star count (left) and matched-star count (right). - bottom_y = res_y - 9 + # Bottom label: detected-star count (the self-contained detector). outline_text( self.draw, - (2, bottom_y), + (plot_left, res_y - 9), _("det {n}").format(n=detected), align="left", font=self.fonts.small, @@ -373,29 +362,32 @@ def x_of(ts): shadow_color=(0, 0, 0), stroke=1, ) - if cue: - outline_text( - self.draw, - (res_x // 2, bottom_y), - _("BACK UP"), - align="center", - font=self.fonts.small, - fill=bright, - shadow_color=(0, 0, 0), - stroke=1, - anchor="ma", - ) - outline_text( - self.draw, - (res_x - 2, bottom_y), - f"{self._STAR_ICON} {self._matched_star_text()}", - align="left", - font=self.fonts.small, - fill=medium, - shadow_color=(0, 0, 0), - stroke=1, - anchor="ra", - ) + + # --- V-curve + best-focus marker over the rolling window --- + now = time.time() + window_start = now - FOCUS_WINDOW_S + span = max(plot_right - plot_left, 1) + + def x_of(ts): + frac = (ts - window_start) / FOCUS_WINDOW_S + return int(plot_left + min(max(frac, 0.0), 1.0) * span) + + marker = self._focus_marker() + if marker is not None: + marker_y = self._hfd_to_y(marker, plot_top, plot_bottom) + self.draw.line([(plot_left, marker_y), (plot_right, marker_y)], fill=dim) + + prev = None + for ts, hfd in self.focus_history: + if hfd is None: + prev = None # gap -- break the line + continue + point = (x_of(ts), self._hfd_to_y(hfd, plot_top, plot_bottom)) + if prev is not None: + self.draw.line([prev, point], fill=bright) + else: + self.draw.point(point, fill=bright) + prev = point def update(self, force=False): if force: From 65c8220161294cf9b23967d341c4a37d606248c9 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 2 Jun 2026 20:12:57 -0700 Subject: [PATCH 4/4] Focus screen: remove the camera reticle The HFD readout and V-curve now carry the focus workflow, so the central concentric-circle reticle no longer adds anything on the Focus screen. - Drop draw_reticle() and its zoom-0 call site, plus the unused reticle_mode attribute and the orphaned camera_reticle config option (no settings UI). - Fix a stale comment: the display stretch is numpy + dither, not a LUT. - Note in the Positioning doc that solve_pixel(screen_space=True) now has no UI consumer (the reticle was its only one); the accessor stays. The Chart/Align Telrad reticle (chart_reticle) is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ax/positioning.md | 5 +++-- python/PiFinder/ui/preview.py | 36 ++++------------------------------- 2 files changed, 7 insertions(+), 34 deletions(-) diff --git a/docs/ax/positioning.md b/docs/ax/positioning.md index b612d864e..ddabd8fd7 100644 --- a/docs/ax/positioning.md +++ b/docs/ax/positioning.md @@ -238,8 +238,9 @@ eyepiece pointing, never the camera pointing. - `solve_pixel` is stored as `(Y, X)` in camera-image space (512×512). - `shared_state.solve_pixel(screen_space=True)` returns `(X, Y)` - rescaled to display space (128×128 = camera/4) for UI overlays - (`ui/preview.py` draws the reticle at this point). + rescaled to display space (128×128 = camera/4) for UI overlays. + (No UI currently consumes this form — the Focus-screen reticle that + did was removed; the accessor remains for future overlays.) - Resetting alignment in the UI calls `shared_state.set_solve_pixel((256, 256))` and writes the same value to `Config` — i.e. recenter the eyepiece pixel on the image center. diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index e14e468a9..17c2872eb 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -4,8 +4,8 @@ This module contains the UIPreview class, a UI module for displaying and interacting with camera images. It handles image processing and provides zoom -functionality. It also manages a marking menu for adjusting camera settings and draws reticles and star -selectors on the images. +functionality. It also manages a marking menu for adjusting camera settings and draws the focus +strip and star selectors on the images. """ import sys @@ -48,7 +48,6 @@ class UIPreview(UIModule): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.reticle_mode = 2 self.last_update = time.time() self.solution = None @@ -152,30 +151,6 @@ def _apply_stretch(self, image_obj): np.clip(stretched, 0, 255, out=stretched) return Image.fromarray(stretched.astype(np.uint8), mode="L") - def draw_reticle(self): - """ - draw the reticle if desired - """ - reticle_brightness = self.config_object.get_option("camera_reticle", 128) - if reticle_brightness == 0: - # None.... - return - - fov = 10.2 - solve_pixel = self.shared_state.solve_pixel(screen_space=True) - for circ_deg in [4, 2, 0.5]: - circ_rad = ((circ_deg / fov) * self.display_class.resX) / 2 - bbox = [ - solve_pixel[0] - circ_rad, - solve_pixel[1] - circ_rad, - solve_pixel[0] + circ_rad, - solve_pixel[1] + circ_rad, - ] - self.draw.arc(bbox, 20, 70, fill=self.colors.get(reticle_brightness)) - self.draw.arc(bbox, 110, 160, fill=self.colors.get(reticle_brightness)) - self.draw.arc(bbox, 200, 250, fill=self.colors.get(reticle_brightness)) - self.draw.arc(bbox, 290, 340, fill=self.colors.get(reticle_brightness)) - def draw_star_selectors(self): # Draw star selectors if self.star_list.shape[0] > 0: @@ -425,8 +400,8 @@ def update(self, force=False): image_obj = image_obj.crop((192, 192, 320, 320)) # Background-anchored linear stretch (replaces autocontrast), then RED. - # Stretch on a single-band image so the 256-entry LUT applies cleanly - # (debug frames are RGB; hardware frames are already mode "L"). + # 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) image_obj = image_obj.convert("RGB") @@ -435,9 +410,6 @@ def update(self, force=False): self.screen.paste(image_obj) self.last_update = last_image_time - if self.zoom_level == 0: - self.draw_reticle() - # Image paste cleared the screen, so redraw overlays after a paste. if image_updated or force: if self.zoom_level > 0: