Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 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
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
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
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
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
Binary file added help/sqm/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added help/sqm/2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
99 changes: 80 additions & 19 deletions python/PiFinder/camera_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,21 @@ def get_image_loop(
else:
console_queue.put("CAM: Captured")

if command == "capture_exp_sweep":
if command.startswith("capture_exp_sweep"):
# Capture exposure sweep - save both RAW and processed images
# at different exposures for SQM testing
# RAW: 16-bit TIFF to preserve full sensor bit depth
# Processed: 8-bit PNG from normal camera.capture() pipeline

# Parse reference SQM if provided
reference_sqm = None
if ":" in command:
try:
reference_sqm = float(command.split(":")[1])
logger.info(f"Reference SQM: {reference_sqm:.2f}")
except (ValueError, IndexError):
logger.warning("Invalid reference SQM in command")

logger.info(
"Starting exposure sweep capture (100 image pairs)"
)
Expand All @@ -410,19 +420,27 @@ def get_image_loop(
min_exp, max_exp, num_images
)

# Generate timestamp for this sweep session
timestamp = datetime.datetime.now().strftime(
"%Y%m%d_%H%M%S"
)
# Generate timestamp for this sweep session using GPS time
gps_time = shared_state.datetime()
if gps_time:
timestamp = gps_time.strftime("%Y%m%d_%H%M%S")
else:
# Fallback to Pi time if GPS not available
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
logger.warning("GPS time not available, using Pi system time for sweep directory name")

# Create sweep directory
sweep_dir = f"{utils.data_dir}/captures/sweep_{timestamp}"
os.makedirs(sweep_dir, exist_ok=True)
from pathlib import Path
sweep_dir = Path(f"{utils.data_dir}/captures/sweep_{timestamp}")
sweep_dir.mkdir(parents=True, exist_ok=True)

logger.info(f"Saving sweep to: {sweep_dir}")
console_queue.put(f"CAM: {num_images} image pairs")
console_queue.put("CAM: Starting sweep...")

for i, exp_us in enumerate(sweep_exposures, 1):
# Update progress at start of each capture
console_queue.put(f"CAM: Sweep {i}/{num_images}")

# Set exposure
self.exposure_time = exp_us
self.set_camera_config(self.exposure_time, self.gain)
Expand All @@ -439,19 +457,14 @@ def get_image_loop(
# Now capture both processed and RAW images with correct exposure
exp_ms = exp_us / 1000

# Save processed PNG (8-bit, from camera.capture())
processed_filename = f"{sweep_dir}/img_{i:03d}_{exp_ms:.2f}ms_processed.png"
self.capture_file(processed_filename)
# Save processed 8-bit PNG (same as production capture() method)
processed_filename = sweep_dir / f"img_{i:03d}_{exp_ms:.2f}ms_processed.png"
processed_img = self.capture() # Returns 8-bit PIL Image
processed_img.save(str(processed_filename))

# Save RAW TIFF (16-bit, from camera.capture_raw_file())
raw_filename = (
f"{sweep_dir}/img_{i:03d}_{exp_ms:.2f}ms_raw.tiff"
)
self.capture_raw_file(raw_filename)

# Update console every 10 images to avoid spam
if i % 10 == 0 or i == num_images:
console_queue.put(f"CAM: Sweep {i}/{num_images}")
raw_filename = sweep_dir / f"img_{i:03d}_{exp_ms:.2f}ms_raw.tiff"
self.capture_raw_file(str(raw_filename))

logger.debug(
f"Captured sweep images {i}/{num_images}: {exp_ms:.2f}ms (PNG+TIFF)"
Expand All @@ -463,6 +476,54 @@ def get_image_loop(
self._auto_exposure_enabled = original_ae_enabled
self.set_camera_config(self.exposure_time, self.gain)

# Save sweep metadata (GPS time, location, altitude)
logger.info("Starting sweep metadata save...")
try:
from PiFinder.sqm.save_sweep_metadata import (
save_sweep_metadata,
)

# Get GPS datetime (not Pi time)
gps_datetime = shared_state.datetime()
logger.debug(f"GPS datetime: {gps_datetime}")

# Get observer location
location = shared_state.location()
logger.debug(f"Location: lat={location.lat}, lon={location.lon}, alt={location.altitude}")

# Get current solve with RA/Dec/Alt/Az
solve_state = shared_state.solution()
ra_deg = None
dec_deg = None
altitude_deg = None
azimuth_deg = None

if solve_state:
ra_deg = solve_state.get("RA")
dec_deg = solve_state.get("Dec")
altitude_deg = solve_state.get("Alt")
azimuth_deg = solve_state.get("Az")
logger.debug(f"Solve: RA={ra_deg}, Dec={dec_deg}, Alt={altitude_deg}, Az={azimuth_deg}")

# Save metadata
logger.info(f"Calling save_sweep_metadata for {sweep_dir}")
save_sweep_metadata(
sweep_dir=sweep_dir,
observer_lat=location.lat,
observer_lon=location.lon,
observer_altitude_m=location.altitude,
gps_datetime=gps_datetime.isoformat() if gps_datetime else None,
reference_sqm=reference_sqm,
ra_deg=ra_deg,
dec_deg=dec_deg,
altitude_deg=altitude_deg,
azimuth_deg=azimuth_deg,
notes=f"Exposure sweep: {num_images} images, {min_exp/1000:.1f}-{max_exp/1000:.1f}ms",
)
logger.info(f"Successfully saved sweep metadata to {sweep_dir}/sweep_metadata.json")
except Exception as e:
logger.error(f"Failed to save sweep metadata: {e}", exc_info=True)

console_queue.put("CAM: Sweep done!")
logger.info(
f"Exposure sweep completed: {num_images} image pairs in {sweep_dir}"
Expand Down
102 changes: 83 additions & 19 deletions python/PiFinder/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,31 +463,95 @@ def solver(
f"RMSE: {solved.get('RMSE', 0):.1f}px"
)

# See if we are waiting for alignment
if align_ra != 0 and align_dec != 0:
if solved.get("x_target") is not None:
align_target_pixel = (
solved["y_target"],
solved["x_target"],
)
logger.debug(f"Align {align_target_pixel=}")
align_result_queue.put(
["aligned", align_target_pixel]
if "matched_centroids" in solution:
# Update SQM for BOTH processed and raw pipelines
# Convert exposure time from microseconds to seconds
exposure_sec = (
last_image_metadata["exposure_time"] / 1_000_000.0
)

update_sqm_dual_pipeline(
shared_state=shared_state,
sqm_calculator=sqm_calculator,
sqm_calculator_raw=sqm_calculator_raw,
camera_command_queue=camera_command_queue,
centroids=centroids,
solution=solution,
image_processed=np_image,
exposure_sec=exposure_sec,
altitude_deg=solved.get("Alt") or 90.0,
calculation_interval_seconds=SQM_CALCULATION_INTERVAL_SECONDS,
)

# Don't clutter printed solution with these fields.
del solution["matched_catID"]
del solution["pattern_centroids"]
del solution["epoch_equinox"]
del solution["epoch_proper_motion"]
del solution["cache_hit_fraction"]

solved |= solution

total_tetra_time = t_extract + solved["T_solve"]
if total_tetra_time > 1000:
console_queue.put(f"SLV: Long: {total_tetra_time}")
logger.warning("Long solver time: %i", total_tetra_time)

if solved["RA"] is not None:
# RA, Dec, Roll at the center of the camera's FoV:
solved["camera_center"]["RA"] = solved["RA"]
solved["camera_center"]["Dec"] = solved["Dec"]
solved["camera_center"]["Roll"] = solved["Roll"]

# RA, Dec, Roll at the center of the camera's not imu:
solved["camera_solve"]["RA"] = solved["RA"]
solved["camera_solve"]["Dec"] = solved["Dec"]
solved["camera_solve"]["Roll"] = solved["Roll"]
# RA, Dec, Roll at the target pixel:
solved["RA"] = solved["RA_target"]
solved["Dec"] = solved["Dec_target"]
if last_image_metadata["imu"]:
solved["imu_pos"] = last_image_metadata["imu"]["pos"]
solved["imu_quat"] = last_image_metadata["imu"]["quat"]
else:
solved["imu_pos"] = None
solved["imu_quat"] = None
solved["solve_time"] = time.time()
solved["cam_solve_time"] = solved["solve_time"]
# Mark successful solve - use same timestamp as last_solve_attempt for comparison
solved["last_solve_success"] = solved["last_solve_attempt"]

logger.info(
f"Solve SUCCESS - {len(centroids)} centroids → "
f"{solved.get('Matches', 0)} matches, "
f"RMSE: {solved.get('RMSE', 0):.1f}px"
)

# See if we are waiting for alignment
if align_ra != 0 and align_dec != 0:
if solved.get("x_target") is not None:
align_target_pixel = (
solved["y_target"],
solved["x_target"],
)
logger.debug(f"Align {align_target_pixel=}")
align_result_queue.put(
["aligned", align_target_pixel]
)
align_ra = 0
align_dec = 0
solved["x_target"] = None
solved["y_target"] = None
else:
# Centroids found but solve failed - clear Matches
solved["Matches"] = 0
logger.warning(
f"Solve FAILED - {len(centroids)} centroids detected but "
f"pattern match failed (FOV est: 12.0°, max err: 4.0°)"
)
else:
# Centroids found but solve failed - clear Matches
solved["Matches"] = 0
logger.warning(
f"Solve FAILED - {len(centroids)} centroids detected but "
f"pattern match failed (FOV est: 12.0°, max err: 4.0°)"
)

# Always push to queue after every solve attempt (success or failure)
solver_queue.put(solved)
# Always push to queue after every solve attempt (success or failure)
solver_queue.put(solved)
except Exception as e:
# If solve attempt fails, still send update with Matches=0
# so auto-exposure can continue running
Expand Down
10 changes: 5 additions & 5 deletions python/PiFinder/sqm/noise_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,12 +354,12 @@ def load_calibration(self) -> bool:
True if calibration was loaded successfully, False otherwise
"""
try:
# Load from ~/PiFinder_data/sqm_calibration.json
# Load from ~/PiFinder_data/sqm_calibration_{camera_type}.json
data_dir = Path.home() / "PiFinder_data"
calibration_file = data_dir / "sqm_calibration.json"
calibration_file = data_dir / f"sqm_calibration_{self.camera_type}.json"

if not calibration_file.exists():
logger.info("No saved calibration found, using default profile")
logger.info(f"No saved calibration found for {self.camera_type}, using default profile")
return False

with open(calibration_file, "r") as f:
Expand Down Expand Up @@ -410,11 +410,11 @@ def save_calibration(
"timestamp": time.time(),
}

# Save to ~/PiFinder_data/sqm_calibration.json
# Save to ~/PiFinder_data/sqm_calibration_{camera_type}.json
data_dir = Path.home() / "PiFinder_data"
data_dir.mkdir(exist_ok=True)

calibration_file = data_dir / "sqm_calibration.json"
calibration_file = data_dir / f"sqm_calibration_{self.camera_type}.json"
with open(calibration_file, "w") as f:
json.dump(calibration_data, f, indent=2)

Expand Down
103 changes: 103 additions & 0 deletions python/PiFinder/sqm/save_sweep_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
Helper to save sweep metadata during capture.
Add this to your sweep capture code.
"""

import json
import logging
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, Any
import pytz

logger = logging.getLogger("SweepMetadata")


def save_sweep_metadata(
sweep_dir: Path,
observer_lat: float,
observer_lon: float,
observer_altitude_m: Optional[float] = None,
gps_datetime: Optional[str] = None,
reference_sqm: Optional[float] = None,
ra_deg: Optional[float] = None,
dec_deg: Optional[float] = None,
altitude_deg: Optional[float] = None,
azimuth_deg: Optional[float] = None,
notes: str = ""
):
"""
Save metadata file in sweep directory.

Call this during sweep capture to save observation details.

Args:
sweep_dir: Path to sweep directory
observer_lat: Observer latitude in degrees
observer_lon: Observer longitude in degrees
observer_altitude_m: Observer altitude in meters (optional)
gps_datetime: GPS datetime as ISO string (optional)
reference_sqm: Reference SQM value from external meter (optional)
ra_deg: Right Ascension from solver (optional)
dec_deg: Declination from solver (optional)
altitude_deg: Altitude angle above horizon in degrees (optional)
azimuth_deg: Azimuth angle in degrees (optional)
notes: Any additional notes
"""
metadata: Dict[str, Any] = {
'timestamp': gps_datetime if gps_datetime else datetime.now(pytz.timezone('Europe/Brussels')).isoformat(),
'observer': {
'latitude_deg': observer_lat,
'longitude_deg': observer_lon,
},
'sweep_directory': str(sweep_dir),
}

if observer_altitude_m is not None:
metadata['observer']['altitude_m'] = observer_altitude_m

if reference_sqm is not None:
metadata['reference_sqm'] = reference_sqm

if ra_deg is not None and dec_deg is not None:
metadata['coordinates'] = {
'ra_deg': ra_deg,
'dec_deg': dec_deg,
}
if altitude_deg is not None:
metadata['coordinates']['altitude_deg'] = altitude_deg
if azimuth_deg is not None:
metadata['coordinates']['azimuth_deg'] = azimuth_deg

if notes:
metadata['notes'] = notes

# Save to JSON file
metadata_file = sweep_dir / 'sweep_metadata.json'
logger.info(f"Writing metadata to: {metadata_file}")

try:
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
logger.info(f"Successfully saved metadata to {metadata_file}")
except Exception as e:
logger.error(f"Failed to write metadata file: {e}")
raise

return metadata_file


# Example usage - add this to your sweep capture code:
if __name__ == "__main__":
from pathlib import Path

# Example: Save metadata during sweep
sweep_dir = Path("../test_images/sweep/sweep_20251116_132256_187sqm")

save_sweep_metadata(
sweep_dir=sweep_dir,
observer_lat=50.8503, # Brussels
observer_lon=4.3517,
reference_sqm=18.7,
notes="Diaphragm fully open, clear sky"
)
Loading
Loading