Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
136 commits
Select commit Hold shift + click to select a range
a035b80
Adding first version of sqm measurement
mrosseel Oct 21, 2024
e7fa2a1
Added UI screen, some refactoring for jupyter debugging
mrosseel Oct 22, 2024
f1f9b2f
Fix crash on no solution
mrosseel Oct 22, 2024
5eebb12
No shared_state crash
mrosseel Oct 22, 2024
34dc9e4
Protect against various crash situations
mrosseel Oct 22, 2024
6e111c7
Swapping centroid y/x
mrosseel Oct 22, 2024
8429089
Added bortle classes, radius=3
mrosseel Oct 22, 2024
d7d962e
Now try radius=2
mrosseel Oct 22, 2024
d8674d9
Raw output for camera in shared state
brickbots Oct 23, 2024
76b26a6
Introducing bias image
mrosseel Oct 23, 2024
eef32af
feeding linear image through pipeline
brickbots Oct 24, 2024
439d012
Merge remote-tracking branch 'origin/raw_output' into sqm
mrosseel Oct 24, 2024
d4d5b29
Delete and ignore comets file, sqm fixes
mrosseel Oct 24, 2024
4888e4f
Fix bias image bug
mrosseel Oct 24, 2024
8dabba8
Missing bias image arguments
mrosseel Oct 24, 2024
b75f52e
Another raw_image mention removed
mrosseel Oct 24, 2024
4b61770
Add debugging output
mrosseel Oct 24, 2024
95d13c5
Merged main with the location changes
mrosseel Mar 17, 2025
c872394
no ubx yet
mrosseel Mar 19, 2025
51f14f4
Merge branch 'main' of github.com:brickbots/PiFinder
mrosseel Mar 25, 2025
0ec4de8
Revert "no ubx yet"
mrosseel Mar 25, 2025
bbcad5c
Merge remote-tracking branch 'origin/main'
mrosseel Mar 27, 2025
fd44825
ZMerge branch 'main' of github.com:mrosseel/PiFinder
mrosseel Apr 5, 2025
64f8401
Merge remote-tracking branch 'origin/main'
mrosseel Apr 6, 2025
b46636f
Merge remote-tracking branch 'origin/main'
mrosseel Apr 6, 2025
77f79bd
Merge remote-tracking branch 'origin/main'
mrosseel Apr 6, 2025
30fa0dc
Merge branch 'main' of github.com:brickbots/PiFinder
mrosseel Jun 17, 2025
0a71bb5
Merge remote-tracking branch 'upstream'
mrosseel Jul 10, 2025
f4738fe
Merge remote-tracking branch 'upstream/main'
mrosseel Jul 28, 2025
9c508bc
Merge remote-tracking branch 'upstream/main'
mrosseel Sep 22, 2025
56ca0fd
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Oct 12, 2025
eee4a59
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Oct 19, 2025
9572a82
Final version of sqm ui
mrosseel Oct 22, 2025
28c5bc5
Add unit test
mrosseel Oct 22, 2025
cef6ec6
Removed bias, added performance code
mrosseel Oct 22, 2025
bf95c2f
speed up per-star calculations
mrosseel Oct 22, 2025
c1ee26a
Less info logging, translations, fix nox error
mrosseel Oct 22, 2025
4695c55
fix messages
mrosseel Oct 22, 2025
43d1905
Updated other languages with translation
mrosseel Oct 22, 2025
e9b35f9
Perform SQM every 5 secs, prettify the SQM ui
mrosseel Oct 23, 2025
09d567c
Fix linting error
mrosseel Oct 24, 2025
7d8f1e0
Some raw and pedestal changes
mrosseel Oct 31, 2025
29ed64c
Add default dark frames
mrosseel Nov 9, 2025
c978ffa
Better noise floor
mrosseel Nov 9, 2025
c125492
fix exposure
mrosseel Nov 9, 2025
a28080b
leave exposure
mrosseel Nov 9, 2025
c66de02
Noise floor fixes
mrosseel Nov 9, 2025
4c2c5bf
processed vs raw
mrosseel Nov 9, 2025
2d4651a
marking menu for pro sqm
mrosseel Nov 9, 2025
155569a
add sqm calibration
mrosseel Nov 9, 2025
1c917f4
Immediately reload measurements
mrosseel Nov 9, 2025
d08d6e1
improve sweeps and camera type, maybe camera type should be reverted
mrosseel Nov 9, 2025
d56837b
Raw pipeline
mrosseel Nov 9, 2025
8da0875
Merge remote-tracking branch 'upstream/main'
mrosseel Nov 10, 2025
0db137b
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Nov 10, 2025
f33eda0
Cleanup
mrosseel Nov 10, 2025
d96d4d4
Cleanup after partial PR review
mrosseel Nov 11, 2025
5ad2b6d
Refactoring camera handling and sqm calibration
mrosseel Nov 11, 2025
c371f59
Do sqm calibration captures in manual mode
mrosseel Nov 11, 2025
fdd2c25
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Nov 12, 2025
73da480
small AE and sqm fixes
mrosseel Nov 13, 2025
5a525a0
Solve stuck
mrosseel Nov 13, 2025
adec86d
show background during sqm
mrosseel Nov 13, 2025
a7e7457
Fix name error
mrosseel Nov 13, 2025
1cf3924
another fix
mrosseel Nov 13, 2025
3cd3386
resize
mrosseel Nov 13, 2025
368d9de
Stretch image
mrosseel Nov 13, 2025
7f818bc
Merge branch 'main' of github.com:brickbots/PiFinder
mrosseel Nov 15, 2025
ee454f5
Merge remote-tracking branch 'upstream/main'
mrosseel Nov 16, 2025
1046f03
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Nov 16, 2025
b0ab3e7
Fix SQM sweep capture for production readiness
mrosseel Nov 17, 2025
390990e
Fix cedar-detect shared memory crashes and variable shadowing
mrosseel Nov 17, 2025
686830a
Add real-time progress UI for exposure sweep
mrosseel Nov 17, 2025
c3420f8
fix crash on sweep
mrosseel Nov 17, 2025
e5f0712
time based progress of sweep
mrosseel Nov 17, 2025
7e837fc
Fix sweep
mrosseel Nov 17, 2025
e56f33e
Use GPS time
mrosseel Nov 17, 2025
35c63ab
Fix json writing
mrosseel Nov 17, 2025
29b7bb2
fix altitude calc
mrosseel Nov 17, 2025
2da9b01
Improve exposure sweep
mrosseel Nov 18, 2025
8f76397
Better counting and solver crah
mrosseel Nov 18, 2025
2675824
more solver indenting
mrosseel Nov 18, 2025
7d0262d
Improve sweep UI
mrosseel Nov 18, 2025
764e7f1
Handle daytime calibration
mrosseel Nov 18, 2025
145daf6
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Dec 2, 2025
295ff2b
Add correction ui and help
mrosseel Dec 8, 2025
e13853b
Merge branch 'main' into sqm
mrosseel Dec 8, 2025
5a5c30c
Merge main into sqm to sync with upstream
mrosseel Dec 10, 2025
bef0497
Merge branch 'sqm' of github.com:mrosseel/PiFinder into sqm
mrosseel Dec 28, 2025
af40ae2
Add sqm debugging sweeps
mrosseel Dec 28, 2025
3409147
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Dec 29, 2025
5e5d0f8
Adding SNR AE
mrosseel Dec 29, 2025
bfc49b4
Merge remote-tracking branch 'upstream/main' into sqm
mrosseel Jan 13, 2026
04a926d
Add rotating constellation/SQM display to title bar
mrosseel Jan 18, 2026
d59daf2
Add exposure time display to SQM view
mrosseel Jan 18, 2026
247959d
Simplify Bortle class display on SQM view
mrosseel Jan 18, 2026
23063c0
Remove dual pipeline references from SQM code
mrosseel Jan 18, 2026
6bec77b
Refactor rotating display into compact helper class
mrosseel Jan 18, 2026
5d811e7
Derive SNR controller thresholds from camera profile
mrosseel Jan 18, 2026
ec78464
Update tetra3 submodule to match main
mrosseel Jan 18, 2026
b13a0b3
Fix KeyError on T_solve when solver fails
mrosseel Jan 18, 2026
58bc08e
Fix update_sqm parameter name mismatch after merge
mrosseel Jan 18, 2026
23fd907
Fix KeyError when deleting optional solution fields
mrosseel Jan 18, 2026
98873c9
Fix camera type detection for SNR controller
mrosseel Jan 18, 2026
0f44f0d
Remove duplicate solver code block from bad merge
mrosseel Jan 18, 2026
cf6f1ac
Add saturation detection to SNR controller
mrosseel Jan 18, 2026
c5542d9
Exclude saturated stars from SQM instead of reducing exposure
mrosseel Jan 18, 2026
0896196
Fix TypeError in SQM correction UI
mrosseel Jan 18, 2026
31e2129
Fix SQM correction: value_raw attribute does not exist
mrosseel Jan 18, 2026
859676b
Target minimum background for shorter exposures
mrosseel Jan 18, 2026
5c6c8f9
Use adaptive noise floor for SNR auto-exposure
mrosseel Jan 18, 2026
0cb24c4
UX: Show 'Saving...' during correction save, target exact noise floor
mrosseel Jan 18, 2026
4923a1c
Add noise_floor and imu_delta to correction metadata
mrosseel Jan 18, 2026
2711aa6
Add full SQM calculation details to shared state and correction metadata
mrosseel Jan 18, 2026
fbf6a9e
Show star count in SQM view
mrosseel Jan 18, 2026
51d7557
Move star count to top line in SQM view
mrosseel Jan 18, 2026
0900255
Add bracketed exposures to correction package
mrosseel Jan 18, 2026
3dcfb97
Reduce exposure sweep from 100 to 20 images
mrosseel Jan 18, 2026
5542e02
Use flux-weighted mean for mzero calculation
mrosseel Jan 18, 2026
aa2a79a
Restore +2 margin above noise floor
mrosseel Jan 18, 2026
7559049
Fix SQM calculation: use bias_offset only as pedestal, disable extinc…
mrosseel Jan 18, 2026
8231d70
Re-enable extinction correction with 0.1 mag/airmass coefficient
mrosseel Jan 18, 2026
8205b51
Add dual extinction correction: fixed 0.1 for meter match, altitude-b…
mrosseel Jan 18, 2026
e9c3f82
Use ASTAP extinction convention: 0.28*(airmass-1), no fixed baseline
mrosseel Jan 18, 2026
b13a0a2
better default for camera
mrosseel Jan 20, 2026
c30ec0f
small corrections to sqm calculation and athmospheric correcitons
mrosseel Jan 22, 2026
59a8348
Simplify SQM API, fix read noise calculation, add Cedar fallback
mrosseel Jan 24, 2026
6f3cea1
Merge branch 'sqm' of github.com:mrosseel/PiFinder into sqm
mrosseel Jan 24, 2026
c180d68
Set SNR as default for testing
mrosseel Jan 25, 2026
22ff554
Fix PID auto-exposure crash and improve sweep recovery
mrosseel Jan 25, 2026
9b14eb9
Update tests for sweep starting at 400ms
mrosseel Jan 25, 2026
c88e343
Improve SQM module: move inline imports to top, add missing tests
mrosseel Jan 25, 2026
df037bd
Simplify SQM: use static bias_offset, combine sweep/correction tools
mrosseel Jan 25, 2026
0095e0b
Remove PID rate limiting and asymmetric gains
mrosseel Jan 25, 2026
4263806
Fix missing right argument in SQM MarkingMenu
mrosseel Jan 25, 2026
2ebfbfb
Fix calibration UI text for bias/dark captures
mrosseel Jan 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 0 additions & 40 deletions .claude/settings.local.json

This file was deleted.

259 changes: 202 additions & 57 deletions python/PiFinder/auto_exposure.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@

The controller targets 17 matched stars (acceptable range: 12-22) which provides
reliable plate solving while avoiding over-saturation and maintaining good performance.
Rate limiting on downward adjustments reduces CPU usage.
"""

import logging
import time
from abc import ABC, abstractmethod
from typing import List, Optional

Expand Down Expand Up @@ -121,10 +119,10 @@ def __init__(
self._repeat_count = 0

# Sweep pattern: exposure values in microseconds
# Uses doubling pattern (2x each step)
# Start at 400ms (reasonable middle), sweep up, then try shorter exposures
# Note: This is intentionally NOT using generate_exposure_sweep() because
# it uses a specific doubling pattern
self._exposures = [25000, 50000, 100000, 200000, 400000, 800000, 1000000]
# it uses a specific pattern optimized for recovery
self._exposures = [400000, 800000, 1000000, 200000, 100000, 50000, 25000]
self._repeats_per_exposure = 2 # Try each exposure 2 times

logger.info(
Expand Down Expand Up @@ -619,6 +617,183 @@ def reset(self) -> None:
logger.debug("HistogramZeroStarHandler reset")


class ExposureSNRController:
"""
SNR-based auto exposure for SQM measurements.

Targets a minimum background SNR and exposure time instead of star count.
This provides more stable, longer exposures that are better for accurate
SQM measurements compared to the histogram-based approach.

Strategy:
- Target specific background level above noise floor
- Derive thresholds from camera profile (bit depth, bias offset)
- Slower adjustments for stability
"""

def __init__(
self,
min_exposure: int = 10000, # 10ms minimum
max_exposure: int = 1000000, # 1.0s maximum
target_background: int = 30, # Target background level in ADU
min_background: int = 15, # Minimum acceptable background
max_background: int = 100, # Maximum before saturating
adjustment_factor: float = 1.3, # Gentle adjustments (30% steps)
):
"""
Initialize SNR-based auto exposure.

Args:
min_exposure: Minimum exposure in microseconds (default 10ms)
max_exposure: Maximum exposure in microseconds (default 1000ms)
target_background: Target median background level in ADU
min_background: Minimum acceptable background (increase if below)
max_background: Maximum acceptable background (decrease if above)
adjustment_factor: Multiplicative adjustment step (default 1.3 = 30%)
"""
self.min_exposure = min_exposure
self.max_exposure = max_exposure
self.target_background = target_background
self.min_background = min_background
self.max_background = max_background
self.adjustment_factor = adjustment_factor

logger.info(
f"AutoExposure SNR: target_bg={target_background}, "
f"range=[{min_background}, {max_background}] ADU, "
f"exp_range=[{min_exposure/1000:.0f}, {max_exposure/1000:.0f}]ms, "
f"adjustment={adjustment_factor}x"
)

@classmethod
def from_camera_profile(
cls,
camera_type: str,
min_exposure: int = 10000,
max_exposure: int = 1000000,
adjustment_factor: float = 1.3,
) -> "ExposureSNRController":
"""
Create controller with thresholds derived from camera profile.

Calculates min/target/max background based on bit depth and bias offset.

Args:
camera_type: Camera type (e.g., "imx296_processed", "imx462_processed")
min_exposure: Minimum exposure in microseconds
max_exposure: Maximum exposure in microseconds
adjustment_factor: Multiplicative adjustment step

Returns:
ExposureSNRController configured for the camera
"""
from PiFinder.sqm.camera_profiles import get_camera_profile

profile = get_camera_profile(camera_type)

# Derive thresholds from camera specs
max_adu = (2 ** profile.bit_depth) - 1
bias = profile.bias_offset

# min_background: bias + margin (2x bias or bias + 8, whichever larger)
min_background = int(max(bias * 2, bias + 8))

# max_background: ~40% of full range (avoid saturation/nonlinearity)
max_background = int(max_adu * 0.4)

# target_background: just above min (lower = shorter exposure = more linear response)
target_background = min_background + 2

logger.info(
f"SNR controller from {camera_type}: "
f"bit_depth={profile.bit_depth}, bias={bias:.0f}, "
f"thresholds=[{min_background}, {target_background}, {max_background}]"
)

return cls(
min_exposure=min_exposure,
max_exposure=max_exposure,
target_background=target_background,
min_background=min_background,
max_background=max_background,
adjustment_factor=adjustment_factor,
)

def update(
self,
current_exposure: int,
image: Image.Image,
noise_floor: Optional[float] = None,
**kwargs # Ignore other params (matched_stars, etc.)
) -> Optional[int]:
"""
Update exposure based on background level.

Args:
current_exposure: Current exposure in microseconds
image: Current image for analysis
noise_floor: Adaptive noise floor from SQM calculator (if available)
**kwargs: Ignored (for compatibility with PID interface)

Returns:
New exposure in microseconds, or None if no change needed
"""
# Use adaptive noise floor if available, otherwise fall back to static config
# Need margin above noise floor so background_corrected isn't near zero
if noise_floor is not None:
min_bg = noise_floor + 2
else:
min_bg = self.min_background

# Analyze image
if image.mode != "L":
image = image.convert("L")
img_array = np.asarray(image, dtype=np.float32)

# Use 10th percentile as background estimate (dark pixels)
background = float(np.percentile(img_array, 10))

logger.debug(
f"SNR AE: bg={background:.1f}, min={min_bg:.1f} ADU, exp={current_exposure/1000:.0f}ms"
)

# Determine adjustment
new_exposure = None

if background < min_bg:
# Too dark - increase exposure
new_exposure = int(current_exposure * self.adjustment_factor)
logger.info(
f"SNR AE: Background too low ({background:.1f} < {min_bg:.1f}), "
f"increasing exposure {current_exposure/1000:.0f}ms → {new_exposure/1000:.0f}ms"
)
elif background > self.max_background:
# Too bright - decrease exposure
new_exposure = int(current_exposure / self.adjustment_factor)
logger.info(
f"SNR AE: Background too high ({background:.1f} > {self.max_background}), "
f"decreasing exposure {current_exposure/1000:.0f}ms → {new_exposure/1000:.0f}ms"
)
else:
# Background is in acceptable range
logger.debug(f"SNR AE: OK (bg={background:.1f} ADU)")
return None

# Clamp to limits
new_exposure = max(self.min_exposure, min(self.max_exposure, new_exposure))
return new_exposure

def get_status(self) -> dict:
return {
"mode": "SNR",
"target_background": self.target_background,
"min_background": self.min_background,
"max_background": self.max_background,
"min_exposure": self.min_exposure,
"max_exposure": self.max_exposure,
}


class ExposurePIDController:
"""
PID controller for automatic camera exposure adjustment.
Expand All @@ -631,56 +806,37 @@ class ExposurePIDController:
def __init__(
self,
target_stars: int = 17,
gains_decrease: tuple = (
500.0, # Kp: Conservative (was 2000, reduced 75% to prevent crash)
5.0, # Ki: Minimal (was 10, reduced to prevent drift)
250.0, # Kd: Proportional (was 750, reduced 67%)
), # Kp, Ki, Kd for too many stars (conservative descent)
gains_increase: tuple = (
4000.0, # Kp: Moderate aggression (was 8000, reduced 50%)
250.0, # Ki: Moderate (was 500, reduced 50%)
1500.0, # Kd: Moderate (was 3000, reduced 50%)
), # Kp, Ki, Kd for too few stars (faster ascent)
gains: tuple = (2000.0, 100.0, 750.0), # Kp, Ki, Kd
min_exposure: int = 25000,
max_exposure: int = 1000000,
deadband: int = 5,
update_interval: float = 0.5, # Minimum seconds between decreasing adjustments
zero_star_handler: Optional[ZeroStarHandler] = None,
):
"""
Initialize PID controller with asymmetric gains.

Uses conservative gains when decreasing exposure (gentle descent),
aggressive gains when increasing (fast recovery).
"""
"""Initialize PID controller."""
self.target_stars = target_stars
self.gains_decrease = gains_decrease
self.gains_increase = gains_increase
self.gains = gains
self.min_exposure = min_exposure
self.max_exposure = max_exposure
self.deadband = deadband
self.update_interval = update_interval

self._integral = 0.0
self._last_error: Optional[float] = None
self._zero_star_count = 0
self._last_adjustment_time = 0.0
self._nonzero_star_count = 0 # Hysteresis: consecutive non-zero solves
self._zero_star_handler = zero_star_handler or SweepZeroStarHandler(
min_exposure=min_exposure, max_exposure=max_exposure
)

logger.info(
f"AutoExposure PID: target={target_stars}, deadband={deadband}, "
f"update_interval={update_interval}s, "
f"gains_dec={gains_decrease}, gains_inc={gains_increase}, "
f"range=[{min_exposure}, {max_exposure}]µs"
f"gains={gains}, range=[{min_exposure}, {max_exposure}]µs"
)

def reset(self) -> None:
self._integral = 0.0
self._last_error = None
self._zero_star_count = 0
self._last_adjustment_time = 0.0
self._nonzero_star_count = 0
self._zero_star_handler.reset()
logger.debug("PID controller reset")

Expand All @@ -706,24 +862,23 @@ def _handle_zero_stars(
)

def _update_pid(self, matched_stars: int, current_exposure: int) -> Optional[int]:
"""Core PID algorithm with asymmetric gains."""
"""Core PID algorithm."""
error = self.target_stars - matched_stars

if abs(error) <= self.deadband:
return None

# Rate limiting: only when decreasing (too many stars)
# When increasing (too few stars), respond immediately for faster recovery
if error < 0: # Too many stars, going down
current_time = time.time()
time_since_last = current_time - self._last_adjustment_time
if time_since_last < self.update_interval:
return None # Skip debug log for performance
else:
current_time = time.time() # Only get time when needed
kp, ki, kd = self.gains

# Select gains: conservative when decreasing, aggressive when increasing
kp, ki, kd = self.gains_decrease if error < 0 else self.gains_increase
# Reset integral when error changes sign to prevent accumulated integral
# from crashing exposure when conditions change suddenly
# (e.g., going from too many stars to too few stars)
if self._last_error is not None:
if (error > 0 and self._last_error < 0) or (error < 0 and self._last_error > 0):
logger.debug(
f"PID: Error sign changed ({self._last_error:.0f} → {error:.0f}), resetting integral"
)
self._integral = 0.0

# PID calculation
p_term = kp * error
Expand All @@ -746,7 +901,6 @@ def _update_pid(self, matched_stars: int, current_exposure: int) -> Optional[int
self._integral -= overshoot / ki

self._last_error = error
self._last_adjustment_time = current_time

return clamped_exposure

Expand Down Expand Up @@ -796,27 +950,18 @@ def set_target(self, target_stars: int) -> None:
self.target_stars = target_stars
logger.debug(f"Target stars changed: {old_target} → {target_stars}")

def set_gains(
self,
gains_decrease: Optional[tuple] = None,
gains_increase: Optional[tuple] = None,
) -> None:
"""Update PID gains. Each tuple is (Kp, Ki, Kd)."""
if gains_decrease is not None:
self.gains_decrease = gains_decrease
if gains_increase is not None:
self.gains_increase = gains_increase
logger.debug(f"PID gains: dec={self.gains_decrease}, inc={self.gains_increase}")
def set_gains(self, gains: tuple) -> None:
"""Update PID gains. Tuple is (Kp, Ki, Kd)."""
self.gains = gains
logger.debug(f"PID gains: {self.gains}")

def get_status(self) -> dict:
return {
"target_stars": self.target_stars,
"gains_decrease": self.gains_decrease,
"gains_increase": self.gains_increase,
"gains": self.gains,
"integral": self._integral,
"last_error": self._last_error,
"min_exposure": self.min_exposure,
"max_exposure": self.max_exposure,
"deadband": self.deadband,
"update_interval": self.update_interval,
}
Loading