diff --git a/docs/ax/positioning.md b/docs/ax/positioning.md index 8f3f8167..e6af68e7 100644 --- a/docs/ax/positioning.md +++ b/docs/ax/positioning.md @@ -307,8 +307,9 @@ eyepiece direction. Every downstream consumer (UI, web, SkySafari) reads - `target_pixel` is stored as `(Y, X)` in camera-image space (512×512). - `shared_state.target_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_target_pixel((256, 256))` and writes the same value to `Config` — i.e. recenter the eyepiece pixel on the image center. diff --git a/docs/ax/ui/CONTEXT.md b/docs/ax/ui/CONTEXT.md index f1735c4c..f789e89e 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/images/quick_start/CAMERA_focused_hud.png b/docs/source/images/quick_start/CAMERA_focused_hud.png new file mode 100644 index 00000000..597f7e2f Binary files /dev/null and b/docs/source/images/quick_start/CAMERA_focused_hud.png differ diff --git a/docs/source/images/quick_start/CAMERA_unfocused_hud.png b/docs/source/images/quick_start/CAMERA_unfocused_hud.png new file mode 100644 index 00000000..bd414237 Binary files /dev/null and b/docs/source/images/quick_start/CAMERA_unfocused_hud.png differ 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 00000000..2dac0421 Binary files /dev/null and b/docs/source/images/quick_start/focus_strip_docs.png differ diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index 5fb2a71c..60e35c70 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -261,9 +261,9 @@ 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. Starting from scratch — a new build, or a lens that's been moved — set the lens so about 6 mm of thread is showing @@ -281,29 +281,51 @@ dark, or noisy — normal until the camera is near focus. Some examples: .. 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 -Pan your scope until a bright object appears in the camera view. Screw the lens in and out +Pan your scope until a bright object appears in the camera view. Screw the lens in and out to focus; once something star-like is in the FOV and near focus, the preview's image processing will work properly and start dimming the background and highlighting the stars. -Good focus means the quickest solves. Close will work, but use the **+/-** keys to zoom the -view to 2x and 4x and get the stars as tight as you reasonably can. With dark enough skies -and good focus, the camera icon appears in the top right and the current constellation -shows in the title bar. Congratulations — the PiFinder knows where it's pointing! +Along the bottom of the Focus screen is the **focus strip**, which turns focusing from a judgement +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 + +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 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. + +Good focus means the quickest solves. Close will work, but it's worth driving the HFD down to its +lowest point — use the **+/-** keys to zoom the view to 2x and 4x and get the stars as tight as you +reasonably can. With dark enough skies and good focus, the camera icon appears in the top right and +the current constellation shows in the title bar. Congratulations — the PiFinder knows where it's +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 diff --git a/python/PiFinder/focus.py b/python/PiFinder/focus.py new file mode 100644 index 00000000..ec24d6ef --- /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 2f641c6d..1ff02671 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -4,23 +4,39 @@ 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 -import numpy as np import time +from collections import deque -from PIL import ImageChops, ImageOps +import numpy as np +from PIL import Image, 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 +# 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): from PiFinder import tetra3 @@ -32,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 @@ -46,8 +61,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,29 +77,79 @@ def __init__(self, *args, **kwargs): right=MarkingMenuOption(), ) - def draw_reticle(self): + def _reset_focus_state(self): + """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() + 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 + + 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, and updates the EMA display-stretch points. All measurement is on + the raw frame. """ - draw the reticle if desired + 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 + + def _focus_marker(self): + """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. 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. """ - reticle_brightness = self.config_object.get_option("camera_reticle", 128) - if reticle_brightness == 0: - # None.... - return - - fov = 10.2 - target_pixel = self.shared_state.target_pixel(screen_space=True) - for circ_deg in [4, 2, 0.5]: - circ_rad = ((circ_deg / fov) * self.display_class.resX) / 2 - bbox = [ - target_pixel[0] - circ_rad, - target_pixel[1] - circ_rad, - target_pixel[0] + circ_rad, - target_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)) + 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, STRETCH_MIN_SPAN) + scale = 255.0 / span + + 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_star_selectors(self): # Draw star selectors @@ -149,67 +217,177 @@ 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 + def _matched_star_text(self): + """Recent matched-star count (the solver's catalog matches), or '-'. - # Get exposure info - exposure_text = self.format_exposure_display() - - # 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.solve_source if solution else None estimate_time = solution.estimate_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 estimate_time: if time.time() - estimate_time < 10: - star_count_text = str(solution.diagnostics.Matches) + return str(solution.diagnostics.Matches) 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: big HFD readout, V-curve, marker, and HUD. - # Draw exposure text with black outline using utility function + ~38 px bottom band, on by default; square hides it. Persists across all + 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 + + # 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) + + 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: + self.draw.text( + (num_right, num_mid_y), + f"{result.median_hfd:.1f}", + font=big_font.font, + fill=bright, + anchor="rm", + ) + else: + # 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", + ) + + # --- 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, y_offset), - exposure_text, + (plot_left, strip_top), + self.format_exposure_display(), 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, ) + outline_text( + self.draw, + (plot_right, strip_top), + 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", + ) - # Draw star count with NerdFont icon - right-aligned to prevent jitter - stars_text = f"{self._STAR_ICON} {star_count_text}" - + # Bottom label: detected-star count (the self-contained detector). outline_text( self.draw, - (126, y_offset), - stars_text, + (plot_left, res_y - 9), + _("det {n}").format(n=detected), 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 ) + # --- 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: 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: @@ -221,29 +399,31 @@ 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 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") 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 + # 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() @@ -258,6 +438,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 00000000..cb730a45 --- /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)