diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index ffa799142..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(find:*)", - "Bash(jj log:*)", - "Bash(jj status:*)", - "Bash(jj show:*)", - "Bash(jj file list:*)", - "Bash(ls:*)", - "Bash(git checkout:*)", - "Bash(git pull:*)", - "Bash(jj branch:*)", - "Bash(jj git fetch:*)", - "Bash(jj help:*)", - "Bash(jj bookmark:*)", - "Bash(jj describe:*)", - "Bash(jj rebase:*)", - "WebFetch(domain:localhost)", - "mcp__playwright__browser_navigate", - "mcp__playwright__browser_type", - "mcp__playwright__browser_click", - "mcp__playwright__browser_take_screenshot", - "mcp__playwright__browser_snapshot", - "mcp__playwright__browser_press_key", - "mcp__playwright__browser_close", - "Bash(pkill:*)", - "Bash(source:*)", - "Bash(python:*)", - "Bash(pip install:*)", - "Bash(timeout 10 python:*)", - "Bash(grep:*)", - "Bash(nox:*)", - "Bash(# Navigate to python dir and set up venv\nPYTHON_DIR=\"\"/Users/mike/dev/amateur_astro/myPiFinder/wt-radec/python\"\"\ncd \"\"$PYTHON_DIR\"\"\npython3.9 -m venv .venv\nsource .venv/bin/activate\npip install -r requirements.txt\npip install -r requirements_dev.txt)", - "Bash(PYTHON_DIR=\"/Users/mike/dev/amateur_astro/myPiFinder/wt-radec/python\")", - "Bash(pytest:*)", - "Bash(sed:*)", - ], - "deny": [] - } -} diff --git a/python/PiFinder/auto_exposure.py b/python/PiFinder/auto_exposure.py index ea6d05afc..f4b181a62 100644 --- a/python/PiFinder/auto_exposure.py +++ b/python/PiFinder/auto_exposure.py @@ -475,7 +475,17 @@ def _analyze_image_viability(self, image: Image.Image) -> tuple: # Calculate metrics mean = float(np.mean(img_array)) std = float(np.std(img_array)) - saturated = np.sum(img_array > 250) + + # Determine saturation threshold based on image bit depth + # 8-bit images: 0-255 range, saturate at ~250 (98% of max) + # 16-bit images: 0-65535 range, saturate at ~64000 (98% of max) + img_max = np.max(img_array) + if img_max > 1000: # Likely 16-bit (range 0-65535) + saturation_threshold = 64000 # 98% of 16-bit range + else: # 8-bit (range 0-255) + saturation_threshold = 250 # 98% of 8-bit range + + saturated = np.sum(img_array > saturation_threshold) saturation_pct = (saturated / img_array.size) * 100 # Viability criteria from test_find_min_exposure.py @@ -489,6 +499,7 @@ def _analyze_image_viability(self, image: Image.Image) -> tuple: "mean": mean, "std": std, "saturation_pct": saturation_pct, + "saturation_threshold": saturation_threshold, "has_signal": has_signal, "has_structure": has_structure, "not_saturated": not_saturated, diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index 842cf683b..40e5a5390 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -153,16 +153,43 @@ def update_sqm_dual_pipeline( if raw_array is not None: raw_array = np.asarray(raw_array, dtype=np.float32) - # Calculate raw SQM + # Scale centroids and apertures to match raw image size + # Processed image is 512x512, raw image is larger (e.g., 1088x1088 for IMX296) + raw_height, raw_width = raw_array.shape + scale_factor = raw_width / 512.0 + + logger.info( + f"Raw SQM scaling: image={raw_width}x{raw_height}, " + f"scale={scale_factor:.2f}x, n_centroids={len(centroids)}" + ) + + # Scale centroids (y, x) coordinates + centroids_raw = [(y * scale_factor, x * scale_factor) for y, x in centroids] + + # Scale aperture radii proportionally + aperture_radius_raw = int(aperture_radius * scale_factor) + annulus_inner_radius_raw = int(annulus_inner_radius * scale_factor) + annulus_outer_radius_raw = int(annulus_outer_radius * scale_factor) + + logger.info( + f"Raw SQM apertures: r={aperture_radius_raw}, " + f"annulus={annulus_inner_radius_raw}-{annulus_outer_radius_raw}" + ) + + # Scale solution FOV to match raw image (FOV is same, but pixel scale changes) + solution_raw = solution.copy() + # FOV in degrees stays the same, SQM calc will recalculate arcsec/pixel from image size + + # Calculate raw SQM with scaled parameters sqm_value_raw, _ = sqm_calculator_raw.calculate( - centroids=centroids, - solution=solution, + centroids=centroids_raw, + solution=solution_raw, image=raw_array, exposure_sec=exposure_sec, altitude_deg=altitude_deg, - aperture_radius=aperture_radius, - annulus_inner_radius=annulus_inner_radius, - annulus_outer_radius=annulus_outer_radius, + aperture_radius=aperture_radius_raw, + annulus_inner_radius=annulus_inner_radius_raw, + annulus_outer_radius=annulus_outer_radius_raw, ) except Exception as e: diff --git a/python/PiFinder/sqm/sqm.py b/python/PiFinder/sqm/sqm.py index a1250ccbf..08d347448 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -65,13 +65,25 @@ def __init__( else: logger.info("SQM initialized with manual pedestal mode") - def _calc_field_parameters(self, fov_degrees: float) -> None: - """Calculate field of view parameters.""" + def _calc_field_parameters(self, fov_degrees: float, image_shape: tuple) -> None: + """Calculate field of view parameters based on actual image size. + + Args: + fov_degrees: Field of view in degrees + image_shape: (height, width) of the image being analyzed + """ self.fov_degrees = fov_degrees self.field_arcsec_squared = (fov_degrees * 3600) ** 2 - self.pixels_total = 512**2 + # Use actual image dimensions, not hardcoded 512x512 + height, width = image_shape + self.pixels_total = height * width self.arcsec_squared_per_pixel = self.field_arcsec_squared / self.pixels_total + logger.info( + f"SQM field params: {width}x{height} pixels, FOV={fov_degrees:.2f}°, " + f"scale={np.sqrt(self.arcsec_squared_per_pixel):.2f}\"/px" + ) + def _calculate_background( self, image: np.ndarray, centroids: np.ndarray, exclusion_radius: int ) -> float: @@ -380,7 +392,7 @@ def calculate( return None, {} fov_estimate = solution["FOV"] - self._calc_field_parameters(fov_estimate) + self._calc_field_parameters(fov_estimate, image.shape) # Validate solution has matched stars if "matched_centroids" not in solution or "matched_stars" not in solution: @@ -584,10 +596,12 @@ def calculate( "star_mzeros": mzeros, } - logger.debug( - f"SQM: mzero={mzero:.2f}±{np.std(valid_mzeros_for_stats):.2f}, " - f"bg={background_flux_density:.6f} ADU/arcsec², pedestal={pedestal:.2f}, " - f"raw={sqm_raw:.2f}, extinction={extinction_correction:.2f}, final={sqm_final:.2f}" + logger.info( + f"SQM calc: mzero={mzero:.2f}±{np.std(valid_mzeros_for_stats):.2f}, " + f"bg_px={background_per_pixel:.1f}, pedestal={pedestal:.1f}, " + f"bg_corr={background_corrected:.1f}, " + f"bg_density={background_flux_density:.2f} ADU/arcsec², " + f"raw={sqm_raw:.2f}, ext={extinction_correction:.2f}, final={sqm_final:.2f}" ) return sqm_final, details diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index 53a0f0363..93ed62bf3 100644 --- a/python/PiFinder/ui/base.py +++ b/python/PiFinder/ui/base.py @@ -97,6 +97,13 @@ def __init__( # anim timer stuff self.last_update_time = time.time() + # Rotating info: alternates between constellation and SQM value + self.rotating_mode = "constellation" # "constellation" or "sqm" + self.rotating_last_switch = time.time() + self.rotating_switch_interval = 3.0 # Switch every 3 seconds + self.rotating_animation_progress = 1.0 # 0.0 = transitioning, 1.0 = stable + self.rotating_animation_speed = 0.15 # Quick fade: ~7 frames at 30fps + def active(self): """ Called when a module becomes active @@ -196,6 +203,159 @@ def message(self, message, timeout: float = 2, size=(5, 44, 123, 84)): self.ui_state.set_message_timeout(timeout + time.time()) + def _update_rotating_state(self): + """ + Update rotating info state. Handles timing and animation progress. + """ + # Check if it's time to switch modes + current_time = time.time() + if current_time - self.rotating_last_switch >= self.rotating_switch_interval: + # Switch mode + if self.rotating_mode == "constellation": + self.rotating_mode = "sqm" + else: + self.rotating_mode = "constellation" + self.rotating_last_switch = current_time + self.rotating_animation_progress = 0.0 # Start fade transition + + # Animate if in progress + if self.rotating_animation_progress < 1.0: + self.rotating_animation_progress = min( + 1.0, self.rotating_animation_progress + self.rotating_animation_speed + ) + + def _get_rotating_content(self): + """ + Get current and previous content for rotating display. + Returns: (current_text, previous_text) + """ + # Get content to display based on current mode + if self.rotating_mode == "constellation": + # Get constellation from solution + solution = self.shared_state.solution() + if solution and solution.get("constellation"): + current_text = solution["constellation"] + else: + current_text = "---" + # Previous was SQM + sqm_state = self.shared_state.sqm() + if sqm_state and sqm_state.value: + previous_text = f"{sqm_state.value:.1f}" + else: + previous_text = "---" + else: + # Get SQM value (1 decimal place) + sqm_state = self.shared_state.sqm() + if sqm_state and sqm_state.value: + current_text = f"{sqm_state.value:.1f}" + else: + current_text = "---" + # Previous was constellation + solution = self.shared_state.solution() + if solution and solution.get("constellation"): + previous_text = solution["constellation"] + else: + previous_text = "---" + + return current_text, previous_text + + def _draw_titlebar_rotating_info(self, x, y, fg): + """ + Draw rotating constellation/SQM in title bar with quick cross-fade. + + Args: + x: X coordinate for text + y: Y coordinate for text + fg: Foreground color value (already from colors.get()) + """ + self._update_rotating_state() + current_text, previous_text = self._get_rotating_content() + + # Quick cross-fade: fade out old, fade in new + # Progress: 0.0 = show previous, 1.0 = show current + # Use brightness values 0-64 for title bar + + if self.rotating_animation_progress < 1.0: + # During transition: fade out, then fade in (not overlapping) + # Title bar background is gray (64), text is black (0) + # Fade out: 0 → 64 (black to gray = invisible) + # Fade in: 64 → 0 (gray to black = visible) + + if self.rotating_animation_progress < 0.5: + # First half: fade out previous (black → gray) + brightness = int(64 * self.rotating_animation_progress * 2) + self.draw.text( + (x, y), + previous_text, + font=self.fonts.bold.font, + fill=self.colors.get(brightness), + ) + else: + # Second half: fade in current (gray → black) + brightness = int(64 * (1.0 - (self.rotating_animation_progress - 0.5) * 2)) + self.draw.text( + (x, y), + current_text, + font=self.fonts.bold.font, + fill=self.colors.get(brightness), + ) + else: + # Stable: show current in black for visibility + self.draw.text( + (x, y), + current_text, + font=self.fonts.bold.font, + fill=self.colors.get(0), + ) + + def draw_rotating_info(self, x=10, y=92, font=None): + """ + Draw rotating display that alternates between constellation and SQM value. + Uses quick cross-fade transition. + + Args: + x: X coordinate for text + y: Y coordinate for text + font: Font to use (defaults to self.fonts.bold.font) + """ + if font is None: + font = self.fonts.bold.font + + self._update_rotating_state() + current_text, previous_text = self._get_rotating_content() + + # Sequential fade: fade out, then fade in (not overlapping) + if self.rotating_animation_progress < 1.0: + # Progress 0.0-0.5: fade out previous (255 → 0) + # Progress 0.5-1.0: fade in current (0 → 255) + + if self.rotating_animation_progress < 0.5: + # First half: fade out previous + brightness = int(255 * (1.0 - self.rotating_animation_progress * 2)) + self.draw.text( + (x, y), + previous_text, + font=font, + fill=self.colors.get(brightness), + ) + else: + # Second half: fade in current + brightness = int(255 * (self.rotating_animation_progress - 0.5) * 2) + self.draw.text( + (x, y), + current_text, + font=font, + fill=self.colors.get(brightness), + ) + else: + # Stable: show current at full brightness + self.draw.text( + (x, y), + current_text, + font=font, + fill=self.colors.get(255), + ) + def screen_update(self, title_bar=True, button_hints=True) -> None: """ called to trigger UI updates @@ -267,13 +427,11 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: ) if len(self.title) < 9: - # draw the constellation - constellation = solution["constellation"] - self.draw.text( - (self.display_class.resX * 0.54, 1), - constellation, - font=self.fonts.bold.font, - fill=fg if self._unmoved else self.colors.get(32), + # Draw rotating constellation/SQM wheel (replaces static constellation) + self._draw_titlebar_rotating_info( + x=int(self.display_class.resX * 0.54), + y=1, + fg=fg if self._unmoved else self.colors.get(32), ) else: # no solve yet.... diff --git a/python/PiFinder/ui/sqm.py b/python/PiFinder/ui/sqm.py index 2cd0115f4..a84382ba6 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -142,7 +142,7 @@ def update(self, force=False): ) else: # Main SQM view - # Last calculation time + # Last calculation time and exposure time if sqm_timestamp: elapsed = int(time.time() - sqm_timestamp) if elapsed < 60: @@ -156,26 +156,44 @@ def update(self, force=False): fill=self.colors.get(64), ) + # Show current exposure time (right side) + image_metadata = self.shared_state.last_image_metadata() + if image_metadata and "exposure_time" in image_metadata: + exp_ms = image_metadata["exposure_time"] / 1000 # Convert µs to ms + if exp_ms >= 1000: + exp_str = f"{exp_ms/1000:.2f}s" + else: + exp_str = f"{exp_ms:.0f}ms" + self.draw.text( + (95, 20), + exp_str, + font=self.fonts.base.font, + fill=self.colors.get(64), + ) + self.draw.text( (10, 30), f"{sqm:.2f}", font=self.fonts.huge.font, fill=self.colors.get(192), ) - # Raw SQM value (if available) in smaller text next to main value + + # 16-bit SQM value (raw sensor, left side below units) + # Note: sqm_state.value_raw is the 16-bit raw value if sqm_state.value_raw is not None: self.draw.text( - (95, 50), + (10, 78), f"{sqm_state.value_raw:.2f}", font=self.fonts.base.font, fill=self.colors.get(128), ) self.draw.text( - (95, 62), - "raw", + (48, 78), + "16bit", font=self.fonts.small.font, fill=self.colors.get(64), ) + # Units in small, subtle text self.draw.text( (12, 68), @@ -183,18 +201,15 @@ def update(self, force=False): font=self.fonts.base.font, fill=self.colors.get(64), ) - self.draw.text( - (10, 82), - f"{details['title']}", - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - self.draw.text( - (10, 92), - _("Bortle {bc}").format(bc=details["bortle_class"]), - font=self.fonts.bold.font, - fill=self.colors.get(128), - ) + + # Bortle class + if details: + self.draw.text( + (10, 92), + _("Bortle {bc}").format(bc=details["bortle_class"]), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) # Legend details_text = _("DETAILS")