-
-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathNaztronomy-Smart_Telescope_PP.py
More file actions
2811 lines (2480 loc) · 116 KB
/
Copy pathNaztronomy-Smart_Telescope_PP.py
File metadata and controls
2811 lines (2480 loc) · 116 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
(c) Nazmus Nasir 2025
SPDX-License-Identifier: GPL-3.0-or-later
Naztronomy - Smart Telescope Preprocessing script
Version: 2.0.6
=====================================
The author of this script is Nazmus Nasir (Naztronomy) and can be reached at:
https://www.Naztronomy.com or https://www.YouTube.com/Naztronomy
Join discord for support and discussion: https://discord.gg/yXKqrawpjr
Support me on Patreon: https://www.patreon.com/c/naztronomy
Support me on Buy me a Coffee: https://www.buymeacoffee.com/naztronomy
The following directory is required inside the working directory:
lights/
The following subdirectories are optional:
darks/
flats/
biases/
"""
"""
CHANGELOG:
2.0.6 - Ignore dot files from macs
- Fix black frames check bug
- PR#75 - support compressed fits in lights dir
- Refactored UI code for better maintainability
- Allow safe cancellation of processing
- Added safe deletes
- Added stack weighting option (Noise, Number of Stars, Weighted FWHM)
- Max batch size of 8100 on Windows but default in UI is still 2000 until version is readable by Python OR feature becomes permanent
- Added Dwarf II in telescope name along with DWARFII.
2.0.5 - Bugfix: Black Frames Scan now sees both compressed and uncompressed fits
- Bugfix: Compression turned on at batch instead of run code
2.0.4 - Compression is now an optional checkbox
- Compression is turned off when failed in try/except blocks
- Compression can still be left on if the script crashes another way or the user ends the script manually
- Fix bug: Disable SPCC for Celestron Origin
2.0.3 - Support for new Unistellar/Evscope telescopes (nicastel)
- Compression during processing (enabled by default and controlled by a flag)
- Forcing stacking to use -32b to fix Dwarf3's "milky" stacked image
- Better error handling with clean_ups
- Force local photometry catalog for SPCC
- Better gaia status layout in GUI
- Updated and more Tooltips
- Output stacking details
- Fixed max frames bug for linux and mac
- Updated tolerance for BGE
- Add Dwarf Mini Support
2.0.2 - Small Bug fixes
- Reenable feathering
- Fixed pixel fraction decimal precision
- Added 'DWARF 3' to auto find telescope from FITS header
- Disallow SPCC for Celestron Origin
- Bypasss seqplatesolve false error for now
- Issue #56 - don't crash if there are no lights
2.0.1 - Allowing all os to batch
- Batch min size set to 50. Batch Max Size set based on OS: Windows 2000, Linux/Mac 25000
- Optional Black Frames Check
- Automatic Telescope Detection from FITS Header when available
- Removed feathering. Automatic feathering of panels still work.
- Fallback to regular registration if plate solving fails (which should accommodate any telescope now) and will not mosaic
- Added additional filters: background and star count
- Filters used only if checkbox is checked without default fallback
- Removed rbswapped file for Siril 1.4 RC1
- Full Celestron Origin Support - latest version of Celestron firmware only
2.0.0 - Major version update:
- Refactored code to use Qt6 instead of Tkinter for the GUI
- Exposed extra filter options
- Allow changing batch size
- Accepts master calibration frames (also creates master calibration frames)
- Temporary workaround to cfa debayering bug in Siril when using drizzle and background extraction for seestars
1.1.1 - Bug fixes:
- Fixed Celestron Origin focal length to 335mm
- Fixed clean up for pre-pp files
1.1.0 - Minor version update:
- Added Batching support for 2000+ files on Windows
- Removed Autocrop due to reported errors
- Added support for Dwarf 2 and Celestron Origin
1.0.1 - minor refactoring to work with both .fit and .fits outputs (e.g. result.fit vs result.fits)
- added support autocrop script created by Gottfried Rotter
1.0.0 - initial release
"""
import os
import sys
import math
import shutil
import time
import sirilpy as s
from datetime import datetime
import json
s.ensure_installed("PyQt6", "numpy", "astropy")
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QGridLayout,
QLabel,
QPushButton,
QCheckBox,
QDoubleSpinBox,
QComboBox,
QGroupBox,
QMessageBox,
QFileDialog,
QSpinBox,
QScrollArea,
QProgressBar,
)
from PyQt6.QtCore import pyqtSlot as Slot, Qt, QThread, pyqtSignal
from PyQt6.QtGui import QFont, QShortcut, QKeySequence
from sirilpy import LogColor, NoImageError
from astropy.io import fits
import numpy as np
# from tkinter import filedialog
APP_NAME = "Naztronomy - Smart Telescope Preprocessing"
VERSION = "2.0.6"
BUILD = "20260220"
AUTHOR = "Nazmus Nasir"
WEBSITE = "Naztronomy.com"
YOUTUBE = "YouTube.com/Naztronomy"
TELESCOPES = [
"ZWO Seestar S30",
"ZWO Seestar S30 Pro",
"ZWO Seestar S50",
"Dwarf Mini",
"Dwarf 3",
"Dwarf 2",
"Celestron Origin",
"Unistellar eVscope 1 / eQuinox 1",
"Unistellar eVscope 2 / eQuinox 2",
"Unistellar Odyssey / Odyssey Pro",
]
FILTER_OPTIONS_MAP = {
"ZWO Seestar S30": ["No Filter (Broadband)", "LP (Narrowband)"],
"ZWO Seestar S30 Pro": ["No Filter (Broadband)", "LP (Narrowband)"],
"ZWO Seestar S50": ["No Filter (Broadband)", "LP (Narrowband)"],
"Dwarf Mini": ["Astro filter (UV/IR)", "Dual-Band"],
"Dwarf 3": ["Astro filter (UV/IR)", "Dual-Band"],
"Dwarf 2": ["Astro filter (UV/IR)"],
"Celestron Origin": ["No Filter (Broadband)"],
"Unistellar eVscope 1 / eQuinox 1": ["No Filter (Broadband)"],
"Unistellar eVscope 2 / eQuinox 2": ["No Filter (Broadband)"],
"Unistellar Odyssey / Odyssey Pro": ["No Filter (Broadband)"],
}
FILTER_COMMANDS_MAP = {
"ZWO Seestar S30": {
"No Filter (Broadband)": ["-oscfilter=UV/IR Block"],
"LP (Narrowband)": ["-oscfilter=ZWO Seestar LP"],
},
"ZWO Seestar S30 Pro": {
"No Filter (Broadband)": ["-oscfilter=UV/IR Block"],
"LP (Narrowband)": ["-oscfilter=ZWO Seestar LP"],
},
"ZWO Seestar S50": {
"No Filter (Broadband)": ["-oscfilter=UV/IR Block"],
"LP (Narrowband)": ["-oscfilter=ZWO Seestar LP"],
},
"Dwarf Mini": {
"Astro filter (UV/IR)": ["-oscfilter=UV/IR Block"],
"Dual-Band": [
"-narrowband",
"-rwl=656.28",
"-rbw=18",
"-gwl=500.70",
"-gbw=30",
"-bwl=500.70",
"-bbw=30",
],
},
"Dwarf 3": {
"Astro filter (UV/IR)": ["-oscfilter=UV/IR Block"],
"Dual-Band": [
"-narrowband",
"-rwl=656.28",
"-rbw=18",
"-gwl=500.70",
"-gbw=30",
"-bwl=500.70",
"-bbw=30",
],
},
"Dwarf 2": {"Astro filter (UV/IR)": ["-oscfilter=UV/IR Block"]},
"Celestron Origin": {
"No Filter (Broadband)": ["-oscfilter=UV/IR Block"],
},
}
class WorkerThread(QThread):
finished = pyqtSignal()
error = pyqtSignal(str)
progress = pyqtSignal(int)
def __init__(self, fn, *args, **kwargs):
super().__init__()
self.fn = fn
self.args = args
self.kwargs = kwargs
def run(self):
try:
self.kwargs["progress_callback"] = self.progress.emit
self.kwargs["check_cancel"] = self.isInterruptionRequested
self.fn(*self.args, **self.kwargs)
except Exception as e:
self.error.emit(str(e))
finally:
self.finished.emit()
UI_DEFAULTS = {
"feather_amount": 20,
"drizzle_amount": 1.0,
"pixel_fraction": 1.0,
"max_files_per_batch": 2000,
"win_max_files_per_batch": 2000,
"mac_max_files_per_batch": 25000,
"linux_max_files_per_batch": 25000,
}
class PreprocessingInterface(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(f"{APP_NAME} - v{VERSION}")
self.siril = s.SirilInterface()
# Flags for mosaic mode and drizzle status
# if drizzle is off, images will be debayered on convert
self.drizzle_status = False
self.drizzle_factor = 0
self.filters_status = False
self.initialization_successful = False
# Detect OS and set appropriate max files per batch
self.max_files_per_batch = 2000 # default
if sys.platform.startswith("win"):
self.max_files_per_batch = UI_DEFAULTS["win_max_files_per_batch"]
elif sys.platform.startswith("linux"):
self.max_files_per_batch = UI_DEFAULTS["linux_max_files_per_batch"]
elif sys.platform.startswith("darwin"):
self.max_files_per_batch = UI_DEFAULTS["mac_max_files_per_batch"]
else:
self.max_files_per_batch = UI_DEFAULTS["max_files_per_batch"]
self.spcc_section = None
self.spcc_checkbox = None
self.chosen_telescope = "ZWO Seestar S30"
self.telescope_options = TELESCOPES
self.target_coords = None
self.telescope_combo = None
self.filter_combo = None
self.filter_options_map = FILTER_OPTIONS_MAP
self.current_filter_options = self.filter_options_map["ZWO Seestar S50"]
try:
self.siril.connect()
self.siril.log("Connected to Siril", LogColor.GREEN)
except s.SirilConnectionError:
self.siril.log("Failed to connect to Siril", LogColor.RED)
self.close_dialog()
return
try:
self.siril.cmd("requires", "1.3.6")
except s.CommandError:
self.close_dialog()
return
self.fits_extension = self.siril.get_siril_config("core", "extension")
self.astrometry_gaia_available = False
try:
self.astrometry_gaia_status = self.siril.get_siril_config(
"core", "catalogue_gaia_astro"
)
if (
self.astrometry_gaia_status
and self.astrometry_gaia_status != "(not set)"
and os.path.isfile(self.astrometry_gaia_status)
):
self.astrometry_gaia_available = True
except s.CommandError:
pass
self.photometry_gaia_available = False
try:
self.photometry_gaia_status = self.siril.get_siril_config(
"core", "catalogue_gaia_photo"
)
if (
self.photometry_gaia_status
and self.photometry_gaia_status != "(not set)"
and os.path.isdir(self.photometry_gaia_status)
):
self.photometry_gaia_available = True
except s.CommandError:
pass
self.current_working_directory = self.siril.get_siril_wd()
self.cwd_label_text = ""
self.initial_message()
changed_cwd = False # a way not to run the prompting loop
initial_cwd = os.path.join(self.current_working_directory, "lights")
if os.path.isdir(initial_cwd):
self.siril.log(
f"Current working directory is valid: {self.current_working_directory}",
LogColor.GREEN,
)
self.siril.cmd("cd", f'"{self.current_working_directory}"')
self.cwd_label_text = (
f"Current working directory: {self.current_working_directory}"
)
changed_cwd = True
elif os.path.basename(self.current_working_directory.lower()) == "lights":
msg = "You're currently in the 'lights' directory, do you want to select the parent directory?"
answer = QMessageBox.question(self, "Already in Lights Dir", msg)
if answer == QMessageBox.StandardButton.Yes:
self.siril.cmd("cd", "../")
os.chdir(os.path.dirname(self.current_working_directory))
self.current_working_directory = os.path.dirname(
self.current_working_directory
)
self.cwd_label_text = (
f"Current working directory: {self.current_working_directory}"
)
self.siril.log(
f"Updated current working directory to: {self.current_working_directory}",
LogColor.GREEN,
)
changed_cwd = True
else:
self.siril.log(
f"Current working directory is invalid: {self.current_working_directory}, reprompting...",
LogColor.SALMON,
)
changed_cwd = False
if not changed_cwd:
while True:
prompt_title = (
"Select the parent directory containing the 'lights' directory"
)
selected_dir = QFileDialog.getExistingDirectory(
self,
prompt_title,
self.current_working_directory,
QFileDialog.Option.ShowDirsOnly,
)
if not selected_dir:
self.siril.log(
"Canceled selecting directory. Restart the script to try again.",
LogColor.SALMON,
)
self.siril.disconnect()
self.close()
return # Stop initialization completely
lights_directory = os.path.join(selected_dir, "lights")
if os.path.isdir(lights_directory):
self.siril.cmd("cd", f'"{selected_dir}"')
os.chdir(selected_dir)
self.current_working_directory = selected_dir
self.cwd_label_text = f"Current working directory: {selected_dir}"
self.siril.log(
f"Updated current working directory to: {selected_dir}",
LogColor.GREEN,
)
break
elif os.path.basename(selected_dir.lower()) == "lights":
msg = "The selected directory is the 'lights' directory, do you want to select the parent directory?"
answer = QMessageBox.question(
self,
"Already in Lights Dir",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
parent_dir = os.path.dirname(selected_dir)
self.siril.cmd("cd", f'"{parent_dir}"')
os.chdir(parent_dir)
self.current_working_directory = parent_dir
self.cwd_label_text = f"Current working directory: {parent_dir}"
self.siril.log(
f"Updated current working directory to: {parent_dir}",
LogColor.GREEN,
)
break
else:
msg = f"The selected directory must contain a subdirectory named 'lights'.\nYou selected: {selected_dir}. Please try again."
self.siril.log(msg, LogColor.SALMON)
QMessageBox.critical(
self, "Invalid Directory", msg, QMessageBox.StandardButton.Ok
)
continue
self.create_widgets()
# Initialize fits_files_count before creating widgets
self.fits_files_count = 0
self.set_telescope_from_fits()
# self.setup_shortcuts()
self.initialization_successful = True
def initial_message(self):
msg = f"""Welcome to {APP_NAME} v{VERSION}!
Please watch latest demos on https://youtube.com/Naztronomy which can answer most questions.
Here are some Frequently Asked Questions:
Q: Can it handle telescopes not listed in the dropdown?
A: Yes, but it will not mosaic them. It will do regular star registration.
Q: How do I get support?
A: Join the Naztronomy Discord server for support and discussion. Please have your logs handy.
Q: Where can I find the logs?
A: You can export logs by clicking the download button on the lower right hand side of the console.\n
"""
self.siril_log_long(msg, LogColor.BLUE)
def siril_log_long(self, message: str, color=LogColor.DEFAULT):
"""
Args:
message: The message to log (can be longer than 1022 bytes)
color: LogColor enum value for text color (default: LogColor.DEFAULT)
"""
lines = message.split("\n")
for line in lines:
stripped_line = line.lstrip() # Remove leading whitespace
if stripped_line: # Skip empty lines
self.siril.log(stripped_line, color)
def set_telescope_from_fits(self):
"""Reads the first FITS file in lights directory and sets telescope based on TELESCOP header."""
# Mapping from FITS header values to UI telescope names
# Note: Order matters! Put more specific/longer strings first
telescope_map = {
"ZWO Seestar S30 Pro": "ZWO Seestar S30 Pro",
"ZWO Seestar S30": "ZWO Seestar S30",
"Seestar S50": "ZWO Seestar S50",
"Seestar S30": "ZWO Seestar S30",
"S50": "ZWO Seestar S50",
"DWARF mini": "Dwarf Mini",
"DWARFIII": "Dwarf 3",
"DWARF 3": "Dwarf 3",
"DWARFII": "Dwarf 2",
"DWARF II": "Dwarf 2",
"Origin": "Celestron Origin",
"eVscope v1.0": "Unistellar eVscope 1 / eQuinox 1",
"eVscope v2.0": "Unistellar eVscope 2 / eQuinox 2",
"odyssey": "Unistellar Odyssey / Odyssey Pro",
}
try:
lights_dir = os.path.join(self.current_working_directory, "lights")
fits_files = [
f
for f in os.listdir(lights_dir)
if f.lower().endswith((".fits", ".fit", ".fits.fz", ".fit.fz"))
]
if not fits_files:
return
# Store fits files count to use later
self.fits_files_count = len(fits_files)
self.siril.log(
f"Found {self.fits_files_count} FITS files in lights directory.",
LogColor.BLUE,
)
# Update the label if it exists
if hasattr(self, "files_found_label"):
self.files_found_label.setText(
f"Fit(s) in lights directory: {self.fits_files_count}"
)
first_file = os.path.join(lights_dir, fits_files[0])
with fits.open(first_file) as hdul:
header = hdul[0].header
telescop = header.get("TELESCOP", "")
creator = header.get("CREATOR", "")
camera = header.get("CAMERA", "")
origin = header.get("ORIGIN", "")
# Try to map telescope name, using startswith for partial matches
mapped_telescope = "ZWO Seestar S30" # default
found_match = False
# Filter out empty header values
header_values = [v for v in [telescop, creator, camera] if v]
# Check map against available headers
for telescope_local_name, ui_name in telescope_map.items():
# Check if any of the effective header values start with this key
if any(
val.startswith(telescope_local_name) for val in header_values
):
mapped_telescope = ui_name
found_match = True
# print(f"Matched FITS header to '{mapped_telescope}' using key '{telescope_local_name}'")
break
if origin.startswith("Unistellar"):
instrume = header.get("INSTRUME", "NULL")
# Dict for Unistellar
unistellar_instruments = {
"IMX224": "Unistellar eVscope 1 / eQuinox 1",
"IMX347": "Unistellar eVscope 2 / eQuinox 2",
"IMX415": "Unistellar Odyssey / Odyssey Pro",
}
for instrument, name in unistellar_instruments.items():
if instrume.startswith(instrument):
mapped_telescope = name
found_match = True
break
if not found_match:
self.siril.log(
"Couldn't find Telescope info, setting default:", LogColor.BLUE
)
self.telescope_combo.setCurrentText(mapped_telescope)
self.chosen_telescope = mapped_telescope
self.siril.log(
f"Set telescope to {mapped_telescope} from FITS header",
LogColor.BLUE,
)
except Exception as e:
self.siril.log(f"Error reading telescope from FITS: {e}", LogColor.SALMON)
def fixUnistellarHeaders(self, dir_name):
dir = os.path.join(self.current_working_directory, dir_name)
for file in os.listdir(dir):
if file.upper().endswith("STACKINPUT.FITS") or file.upper().endswith(
"STACKINPUT.FIT"
):
data, hdr = fits.getdata(os.path.join(dir, file), header=True)
hdr.set(
"RA", hdr["FOVRA"]
) # add a RA header based on the FOVRA unistellar header
hdr.set(
"DEC", hdr["FOVDEC"]
) # add a DEC header based on the FOVDEC unistellar header
telescope = None
if hdr["INSTRUME"].startswith("IMX224"): # eVscope1 or eQuinox1
hdr.set("FOCALLEN", 450.0) # add a FOCALLEN header
hdr.set("XPIXSZ", 3.75) # add a XPIXSZ header
hdr.set("YPIXSZ", 3.75) # add a YPIXSZ header
telescope = "eVscope v1.0"
if hdr["INSTRUME"].startswith("IMX347"): # eVscope2 or eQuinox2
hdr.set("FOCALLEN", 450.0) # add a FOCALLEN header
hdr.set("XPIXSZ", 2.9) # add a XPIXSZ header
hdr.set("YPIXSZ", 2.9) # add a YPIXSZ header
telescope = "eVscope v2.0"
if hdr["INSTRUME"].startswith("IMX415"): # Odyssey or Odyssey Pro
hdr.set("FOCALLEN", 320.0) # add a FOCALLEN header
hdr.set("XPIXSZ", 2.9) # add a XPIXSZ header
hdr.set("YPIXSZ", 2.9) # add a YPIXSZ header
telescope = "Odyssey"
if hdr["SOFTVER"].startswith("4.2") and telescope.startswith(
"eVscope"
): # fix for bayer issue with latest FW 4.2
hdr.set("XBAYROFF", 0) # add a XPIXSZ header
hdr.set("YBAYROFF", 1) # add a YPIXSZ header
else:
hdr.set("XBAYROFF", 0) # add a XPIXSZ header
hdr.set("YBAYROFF", 0) # add a YPIXSZ header
if hdr.get("TELESCOP") is None and telescope is not None:
hdr.set(
"TELESCOP", telescope
) # add a TELESCOP header for older FW version
fits.writeto(os.path.join(dir, file), data, hdr, overwrite=True)
# print(file)
self.siril.log("Unistellar headers fixed!", LogColor.GREEN)
# Dirname: lights, darks, biases, flats
def convert_files(self, dir_name):
directory = os.path.join(self.current_working_directory, dir_name)
if os.path.isdir(directory):
self.siril.cmd("cd", dir_name)
file_count = len(
[
name
for name in os.listdir(directory)
if os.path.isfile(os.path.join(directory, name))
and not name.startswith(".")
and (
name.lower().endswith(".fit")
or name.lower().endswith(".fits")
or name.lower().endswith(".fit.fz")
or name.lower().endswith(".fits.fz")
)
]
)
self.siril.log(
f"Found {file_count} files in {dir_name} directory.", LogColor.BLUE
)
if file_count == 1:
self.siril.log(
f"Only one file found in {dir_name} directory. Treating it like a master {dir_name} frame.",
LogColor.BLUE,
)
src = os.path.join(directory, os.listdir(directory)[0])
dst = os.path.join(
self.current_working_directory,
"process",
f"{dir_name}_stacked{self.fits_extension}",
)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
self.siril.log(
f"Copied master {dir_name} to process as {dir_name}_stacked.",
LogColor.BLUE,
)
self.siril.cmd("cd", "..")
# return false because there's no conversion
return False
try:
# args = ["convert", dir_name, "-out=../process"]
# Switched to `link` command to only get fits files
args = ["link", dir_name, "-out=../process"]
# If there are no calibration frames or drizzle is off, debayer on convert, otherwise you get a monochrome image
# if "lights" in dir_name.lower():
# if not self.darks_checkbox.isChecked() or not self.flats_checkbox.isChecked() or not self.drizzle_status:
# args.append("-debayer")
self.siril.log(" ".join(str(arg) for arg in args), LogColor.GREEN)
self.siril.cmd(*args)
except (s.DataError, s.CommandError, s.SirilError) as e:
# turn off compression if error (if checked)
if self.compression_checkbox.isChecked():
self.siril.cmd("setcompress", "0")
self.siril.log(f"File conversion failed: {e}", LogColor.RED)
self.close_dialog()
self.siril.cmd("cd", "../process")
self.siril.log(
f"Converted {file_count} {dir_name} files for processing!",
LogColor.GREEN,
)
return True
else:
self.siril.log(
f'No directory named "{dir_name}" at this location. Make sure the working directory is correct. Skipping.',
LogColor.SALMON,
)
# Plate solve on sequence runs when file count < 2048
def seq_plate_solve(self, seq_name):
"""Runs the siril command 'seqplatesolve' to plate solve the converted files."""
# self.siril.cmd("cd", "process")
args = ["seqplatesolve", seq_name]
if self.chosen_telescope == "Dwarf 2":
args.append(self.target_coords)
focal_len = 100
pixel_size = 1.45
args.append(f"-focal={focal_len}")
args.append(f"-pixelsize={pixel_size}")
args.extend(
["-nocache", "-force", "-disto=ps_distortion", "-order=4", "-radius=25"]
)
try:
self.siril.cmd(*args)
self.siril.log(f"Platesolved {seq_name}", LogColor.GREEN)
return True
except (s.DataError, s.CommandError, s.SirilError) as e:
self.siril.log(f"seqplatesolve failed: {e}", LogColor.RED)
return True # TODO: disabling fallback because Siril seems to be throwing a false error
# Regular registration if plate solve not available - No Mosaics
def regular_register_seq(self, seq_name, drizzle_amount, pixel_fraction):
"""Registers the sequence using the 'register' command."""
cmd_args = ["register", seq_name, "-2pass"]
if self.drizzle_status:
cmd_args.extend(
["-drizzle", f"-scale={drizzle_amount}", f"-pixfrac={pixel_fraction}"]
)
self.siril.log(
"Regular Registration (Global Star Alignment) Done: " + " ".join(cmd_args),
LogColor.BLUE,
)
try:
self.siril.cmd(*cmd_args)
except (s.DataError, s.CommandError, s.SirilError) as e:
# turn off compression if error (if checked)
if self.compression_checkbox.isChecked():
self.siril.cmd("setcompress", "0")
self.siril.log(f"Data error occurred: {e}", LogColor.RED)
self.siril.log("Registered Sequence", LogColor.GREEN)
def seq_bg_extract(self, seq_name):
"""Runs the siril command 'seqsubsky' to extract the background from the plate solved files."""
try:
self.siril.cmd("seqsubsky", seq_name, "1", "-samples=10", "-tolerance=2.0")
self.siril.cmd("cd", ".") # Refresh current directory
self.siril.cmd("close") # Close and reopen to flush cache
self.siril.cmd("cd", ".") # Re-establish working directory
time.sleep(10) # Wait for Siril to flush cache
except (s.DataError, s.CommandError, s.SirilError) as e:
# turn off compression if error (if checked)
if self.compression_checkbox.isChecked():
self.siril.cmd("setcompress", "0")
self.siril.log(f"Seq BG Extraction failed: {e}", LogColor.RED)
self.close_dialog()
self.siril.log("Background extracted from Sequence", LogColor.GREEN)
def seq_apply_reg(
self,
seq_name,
drizzle_amount,
pixel_fraction,
filter_roundness,
filter_fwhm,
filter_bg,
filter_star_count,
):
"""Apply Existing Registration to the sequence."""
cmd_args = [
"seqapplyreg",
seq_name,
"-kernel=square",
"-framing=max",
]
if self.filters_group.isChecked():
cmd_args.extend(
[
f"-filter-round={filter_roundness}%",
f"-filter-wfwhm={filter_fwhm}%",
f"-filter-bkg={filter_bg}%",
f"-filter-nbstars={filter_star_count}%",
]
)
if self.drizzle_status:
cmd_args.extend(
["-drizzle", f"-scale={drizzle_amount}", f"-pixfrac={pixel_fraction}"]
)
self.siril.log("Command arguments: " + " ".join(cmd_args), LogColor.BLUE)
try:
self.siril.cmd(*cmd_args)
except (s.DataError, s.CommandError, s.SirilError) as e:
self.siril.log(f"Data error occurred: {e}", LogColor.RED)
self.siril.log("Registered Sequence", LogColor.GREEN)
def is_black_frame(self, data, threshold=10, crop_fraction=0.4):
if data.ndim > 2:
data = data[0]
ny, nx = data.shape
crop_x = int(nx * crop_fraction)
crop_y = int(ny * crop_fraction)
start_x = (nx - crop_x) // 2
start_y = (ny - crop_y) // 2
crop = data[start_y : start_y + crop_y, start_x : start_x + crop_x]
nonzero = crop[crop != 0]
if nonzero.size == 0:
median_val = 0.0
else:
median_val = np.median(nonzero)
return median_val < threshold, median_val
def scan_black_frames(
self, folder="process", threshold=30, crop_fraction=0.4, seq_name=None
):
black_frames = []
black_indices = []
all_frames_info = []
self.siril.log("Starting scan for black frames...", LogColor.BLUE)
self.siril.log(
"Note: This process is running in the background and may take a while depending on your system and drizzle factor.",
LogColor.BLUE,
)
for idx, filename in enumerate(sorted(os.listdir(folder))):
if filename.startswith(seq_name) and (
filename.lower().endswith(self.fits_extension + ".fz")
or filename.lower().endswith(self.fits_extension)
):
filepath = os.path.join(folder, filename)
try:
with fits.open(filepath) as hdul:
# Try to get data from HDU 1 for compressed files, fall back to HDU 0
data = None
if len(hdul) > 1:
data = hdul[1].data
else:
data = hdul[0].data
if data is not None and data.ndim >= 2:
dynamic_threshold = threshold
data_max = np.max(data)
if (
np.issubdtype(data.dtype, np.floating)
or data_max <= 10.0
):
dynamic_threshold = 0.0001
is_black, median_val = self.is_black_frame(
data, dynamic_threshold, crop_fraction
)
all_frames_info.append((filename, median_val))
# Log for debugging
# print(
# f"{filename} | shape: {data.shape} | dtype: {data.dtype} | min: {np.min(data)} | max: {data_max} | median: {median_val} | threshold used: {dynamic_threshold}"
# )
if is_black:
black_frames.append(filename)
black_indices.append(len(all_frames_info))
else:
self.siril.log(
f"{filename}: Unexpected data shape {data.shape if data is not None else 'None'}",
LogColor.SALMON,
)
except Exception as e:
self.siril.log(f"Error reading {filename}: {e}", LogColor.RED)
self.siril.log(f"Following files are black: {black_frames}", LogColor.SALMON)
self.siril.log(
f"Black indices skipped in stacking: {black_indices}", LogColor.SALMON
)
for index in black_indices:
self.siril.cmd("unselect", seq_name, index, index)
def calibration_stack(self, seq_name):
# not in /process dir here
file_name_end = "_stacked"
if seq_name == "flats":
if os.path.exists(
os.path.join(
self.current_working_directory,
f"process/biases{file_name_end}{self.fits_extension}",
)
):
# Saves as pp_flats
self.siril.cmd("calibrate", "flats", f"-bias=biases{file_name_end}")
self.siril.cmd(
"stack", "pp_flats rej 3 3", "-norm=mul", f"-out={seq_name}_stacked"
)
# self.siril.cmd("cd", "..")
else:
self.siril.cmd(
"stack",
f"{seq_name} rej 3 3",
"-norm=mul",
f"-out={seq_name}_stacked",
)
else:
# Don't run code below for flats
# biases and darks
cmd_args = [
"stack",
f"{seq_name} rej 3 3 -nonorm",
f"-out={seq_name}{file_name_end}",
]
self.siril.log(f"Running command: {' '.join(cmd_args)}", LogColor.BLUE)
try:
self.siril.cmd(*cmd_args)
except (s.DataError, s.CommandError, s.SirilError) as e:
self.siril.log(f"Command execution failed: {e}", LogColor.RED)
self.close_dialog()
self.siril.log(f"Completed stacking {seq_name}!", LogColor.GREEN)
# Copy the stacked calibration files to ../masters directory
masters_dir = os.path.join(self.current_working_directory, "masters")
os.makedirs(masters_dir, exist_ok=True)
src = os.path.join(
self.current_working_directory,
f"process/{seq_name}{file_name_end}{self.fits_extension}",
)
# Read FITS headers if file exists
filename_parts = [seq_name, "stacked"]
if os.path.exists(src):
try:
with fits.open(src) as hdul:
headers = hdul[0].header
# Add temperature if exists
if "CCD-TEMP" in headers:
temp = f"{headers['CCD-TEMP']:.1f}C"
filename_parts.insert(1, temp)
# Add date if exists
if "DATE-OBS" in headers:
try:
dt = datetime.fromisoformat(headers["DATE-OBS"])
date = dt.date().isoformat() # "2025-09-29"
except ValueError:
# fallback if DATE-OBS is not strict ISO format
date = headers["DATE-OBS"].split("T")[0]
filename_parts.insert(1, date)
# Add exposure time if exists
if "EXPTIME" in headers:
exp = f"{headers['EXPTIME']:.0f}s"
filename_parts.insert(1, exp)
except Exception as e:
self.siril.log(f"Error reading FITS headers: {e}", LogColor.SALMON)
dst = os.path.join(
masters_dir, f"{'_'.join(filename_parts)}{self.fits_extension}"
)
if os.path.exists(src):
# Remove destination file if it exists to ensure override
if os.path.exists(dst):
os.remove(dst)
shutil.copy2(src, dst)
self.siril.log(
f"Copied {seq_name} to masters directory as {'_'.join(filename_parts)}{self.fits_extension}",
LogColor.BLUE,
)
self.siril.cmd("cd", "..")
def calibrate_lights(
self, seq_name, use_darks=False, use_flats=False, use_biases=False
):
cmd_args = [
"calibrate",
f"{seq_name}",
]
# Check if darks_stacked exists before adding to command
if use_darks and os.path.exists(
os.path.join(
self.current_working_directory,
"process",
f"darks_stacked{self.fits_extension}",
)
):
cmd_args.append("-dark=darks_stacked")
cmd_args.append("-cc=dark")
if use_flats and os.path.exists(
os.path.join(
self.current_working_directory,
"process",
f"flats_stacked{self.fits_extension}",
)
):
cmd_args.append("-flat=flats_stacked")
if use_biases and os.path.exists(
os.path.join(
self.current_working_directory,
"process",
f"biases_stacked{self.fits_extension}",
)
):
cmd_args.append("-bias=biases_stacked")
cmd_args.extend(["-cfa", "-equalize_cfa"])
# Calibrate with -debayer if drizle is not set
self.siril.log(f"Drizzle status: {self.drizzle_status}", LogColor.BLUE)
if not self.drizzle_status:
cmd_args.append("-debayer")
self.siril.log(f"Running command: {' '.join(cmd_args)}", LogColor.BLUE)
try: